nightshade-editor 0.32.0

Interactive map editor for the Nightshade game engine
//! Converts Sponza curtain meshes into live GPU cloth.
//!
//! The Khronos Sponza sample ships with no node, mesh, or material names,
//! so its curtains cannot be found by name. The importer names its
//! primitives `Primitive_{index}` in glTF order, and the curtains occupy a
//! fixed set of primitive indices: 54 to 61 are the tied column curtains
//! and 63 to 72 are the hanging drapes. Named assets are also supported:
//! any mesh whose name contains "curtain", "fabric", or "drape" is a
//! candidate. Every candidate must additionally pass a sheet-shape check
//! on its world bounds so the button stays inert on unrelated geometry.
//!
//! Each detected curtain mesh is hidden and replaced by a [`Cloth`] entity
//! spanning the mesh's world bounds, pinned along the top edge. The cloth
//! takes over the source mesh's material (cloned with `double_sided` set,
//! since both faces of a cloth sheet are visible), so the original fabric
//! textures, normal maps, and PBR factors carry over.

use crate::ecs::EditorWorld;
use nightshade::ecs::material::material_registry_insert;
use nightshade::ecs::transform::commands::mark_local_transform_dirty;
use nightshade::ecs::world::{BOUNDING_VOLUME, GLOBAL_TRANSFORM, NAME, RENDER_MESH, VISIBILITY};
use nightshade::prelude::*;

const KHRONOS_SPONZA_TIED_CURTAINS: std::ops::RangeInclusive<u32> = 54..=61;
const KHRONOS_SPONZA_DRAPES: std::ops::RangeInclusive<u32> = 63..=72;

const RESOLUTION_PER_METER: f32 = 24.0;
const MIN_GRID: u32 = 16;
const MAX_GRID: u32 = 192;

struct CurtainCandidate {
    entity: Entity,
    minimum: Vec3,
    maximum: Vec3,
    material_name: Option<String>,
}

pub fn convert(editor_world: &mut EditorWorld, world: &mut World) {
    let mut candidates: Vec<CurtainCandidate> = Vec::new();
    world
        .core
        .query()
        .with(RENDER_MESH | BOUNDING_VOLUME | GLOBAL_TRANSFORM | NAME)
        .iter(|entity, table, index| {
            if editor_world
                .resources
                .cloth_sponza
                .converted
                .contains(&entity)
            {
                return;
            }
            if !is_curtain_name(&table.name[index].0) {
                return;
            }
            let (minimum, maximum) = world_bounds(
                &table.bounding_volume[index].obb,
                &table.global_transform[index].0,
            );
            if !is_sheet_shaped(minimum, maximum) {
                return;
            }
            candidates.push(CurtainCandidate {
                entity,
                minimum,
                maximum,
                material_name: None,
            });
        });

    for candidate in &mut candidates {
        candidate.material_name = world
            .core
            .get_material_ref(candidate.entity)
            .map(|material_ref| material_ref.name.clone());
    }

    for (curtain_index, candidate) in candidates.iter().enumerate() {
        let extents = candidate.maximum - candidate.minimum;
        let spans_z = extents.z > extents.x;
        let width = extents.x.max(extents.z);
        let height = extents.y;
        let center = (candidate.minimum + candidate.maximum) / 2.0;
        let top_center = Vec3::new(center.x, candidate.maximum.y, center.z);

        let cloth_entity = spawn_cloth(
            world,
            Cloth {
                columns: grid_resolution(width),
                rows: grid_resolution(height),
                width,
                height,
                pinning: ClothPinning::TopRow,
                ground_height: None,
                ..Default::default()
            },
            top_center,
            format!("Cloth Curtain {}", curtain_index),
        );
        if let Some(material_name) = &candidate.material_name {
            apply_source_material(world, cloth_entity, material_name, curtain_index);
        }
        if spans_z && let Some(transform) = world.core.get_local_transform_mut(cloth_entity) {
            transform.rotation = nalgebra_glm::quat_angle_axis(
                std::f32::consts::FRAC_PI_2,
                &Vec3::new(0.0, 1.0, 0.0),
            );
        }
        mark_local_transform_dirty(world, cloth_entity);

        if !world
            .core
            .entity_has_components(candidate.entity, VISIBILITY)
        {
            world.core.add_components(candidate.entity, VISIBILITY);
        }
        world
            .core
            .set_visibility(candidate.entity, Visibility { visible: false });

        editor_world
            .resources
            .cloth_sponza
            .converted
            .push(candidate.entity);
    }
}

fn apply_source_material(
    world: &mut World,
    cloth_entity: Entity,
    source_material_name: &str,
    curtain_index: usize,
) {
    let Some(source_material) = registry_entry_by_name(
        &world.resources.assets.material_registry.registry,
        source_material_name,
    )
    .cloned() else {
        return;
    };
    let cloth_material = Material {
        double_sided: true,
        ..source_material
    };
    let cloth_material_name = format!("Cloth Sponza Curtain {}", curtain_index);
    material_registry_insert(
        &mut world.resources.assets.material_registry,
        cloth_material_name.clone(),
        cloth_material,
    );
    if let Some(&index) = world
        .resources
        .assets
        .material_registry
        .registry
        .name_to_index
        .get(&cloth_material_name)
    {
        registry_add_reference(
            &mut world.resources.assets.material_registry.registry,
            index,
        );
    }
    world
        .core
        .set_material_ref(cloth_entity, MaterialRef::new(cloth_material_name));
}

fn is_curtain_name(name: &str) -> bool {
    let lowered = name.to_lowercase();
    if lowered.contains("curtain") || lowered.contains("fabric") || lowered.contains("drape") {
        return true;
    }
    name.strip_prefix("Primitive_")
        .and_then(|suffix| suffix.parse::<u32>().ok())
        .is_some_and(|index| {
            KHRONOS_SPONZA_TIED_CURTAINS.contains(&index) || KHRONOS_SPONZA_DRAPES.contains(&index)
        })
}

fn world_bounds(
    obb: &nightshade::ecs::bounding_volume::components::OrientedBoundingBox,
    global_transform: &Mat4,
) -> (Vec3, Vec3) {
    let mut minimum = Vec3::new(f32::MAX, f32::MAX, f32::MAX);
    let mut maximum = Vec3::new(f32::MIN, f32::MIN, f32::MIN);
    for corner_index in 0..8 {
        let sign = Vec3::new(
            if corner_index & 1 == 0 { -1.0 } else { 1.0 },
            if corner_index & 2 == 0 { -1.0 } else { 1.0 },
            if corner_index & 4 == 0 { -1.0 } else { 1.0 },
        );
        let offset = nalgebra_glm::quat_rotate_vec3(
            &obb.orientation,
            &obb.half_extents.component_mul(&sign),
        );
        let local = obb.center + offset;
        let world = global_transform * Vec4::new(local.x, local.y, local.z, 1.0);
        minimum = nalgebra_glm::min2(&minimum, &world.xyz());
        maximum = nalgebra_glm::max2(&maximum, &world.xyz());
    }
    (minimum, maximum)
}

fn is_sheet_shaped(minimum: Vec3, maximum: Vec3) -> bool {
    let extents = maximum - minimum;
    let thin = extents.x.min(extents.z);
    let span = extents.x.max(extents.z);
    thin < span * 0.4 && extents.y > 0.3 && span > 0.3
}

fn grid_resolution(size: f32) -> u32 {
    ((size * RESOLUTION_PER_METER) as u32).clamp(MIN_GRID, MAX_GRID)
}