use crate::prelude::*;
use rusterix::PixelSource;
use rusterix::Surface;
use std::collections::{BTreeSet, HashSet};
use std::str::FromStr;
pub const CREATE_ROOF_ACTION_ID: &str = "9f4b34ad-2f43-4c31-9f41-9f5664c6d5e3";
pub struct CreateRoof {
id: TheId,
nodeui: TheNodeUI,
}
impl CreateRoof {
fn parse_tile_source(text: &str) -> Option<Value> {
Some(Value::Source(parse_tile_id_pixelsource(text)?))
}
fn apply_sector_roof(&self, map: &mut Map, sector_id: u32) -> bool {
let roof_name = self
.nodeui
.get_text_value("actionRoofName")
.unwrap_or_else(|| "Roof".to_string());
let roof_style = self
.nodeui
.get_i32_value("actionRoofStyle")
.unwrap_or(1)
.clamp(0, 2);
let roof_height = self
.nodeui
.get_f32_value("actionRoofHeight")
.unwrap_or(1.0)
.max(0.0);
let roof_overhang = self
.nodeui
.get_f32_value("actionRoofOverhang")
.unwrap_or(0.0)
.max(0.0);
let tile_id_text = self
.nodeui
.get_text_value("actionRoofTileId")
.unwrap_or_default();
let side_tile_id_text = self
.nodeui
.get_text_value("actionRoofSideTileId")
.unwrap_or_default();
let Some(sector) = map.find_sector_mut(sector_id) else {
return false;
};
if roof_height <= 0.0 {
sector
.properties
.set("sector_feature", Value::Str("None".to_string()));
sector.properties.remove("roof_name");
sector.properties.remove("roof_style");
sector.properties.remove("roof_height");
sector.properties.remove("roof_overhang");
sector.properties.remove("roof_tile_source");
sector.properties.remove("roof_side_source");
return true;
}
sector
.properties
.set("sector_feature", Value::Str("Roof".to_string()));
sector.properties.set("roof_name", Value::Str(roof_name));
sector.properties.set("roof_style", Value::Int(roof_style));
sector
.properties
.set("roof_height", Value::Float(roof_height));
sector
.properties
.set("roof_overhang", Value::Float(roof_overhang));
if let Some(src) = Self::parse_tile_source(&tile_id_text) {
sector.properties.set("roof_tile_source", src);
} else {
sector.properties.remove("roof_tile_source");
}
if let Some(src) = Self::parse_tile_source(&side_tile_id_text) {
sector.properties.set("roof_side_source", src);
} else {
sector.properties.remove("roof_side_source");
}
true
}
fn clear_sector_roof(map: &mut Map, sector_id: u32) -> bool {
let Some(sector) = map.find_sector_mut(sector_id) else {
return false;
};
let had_roof = sector
.properties
.get_str_default("sector_feature", "None".to_string())
== "Roof";
if had_roof {
sector
.properties
.set("sector_feature", Value::Str("None".to_string()));
sector.properties.remove("roof_name");
sector.properties.remove("roof_style");
sector.properties.remove("roof_height");
sector.properties.remove("roof_overhang");
sector.properties.remove("roof_tile_source");
sector.properties.remove("roof_side_source");
}
had_roof
}
fn sector_has_horizontal_loop(map: &Map, sector_id: u32) -> bool {
for surface in map.surfaces.values() {
if surface.sector_id != sector_id {
continue;
}
if surface.plane.normal.y.abs() <= 0.7 {
continue;
}
if let Some(loop_uv) = surface.sector_loop_uv(map)
&& loop_uv.len() >= 3
{
return true;
}
}
false
}
fn sector_bbox_area(map: &Map, sector_id: u32) -> f32 {
if let Some(sector) = map.find_sector(sector_id) {
let bbox = sector.bounding_box(map);
let sx = (bbox.max.x - bbox.min.x).abs();
let sy = (bbox.max.y - bbox.min.y).abs();
sx * sy
} else {
0.0
}
}
fn selected_roof_sector_ids(&self, map: &Map) -> Vec<u32> {
let selected: HashSet<u32> = map.selected_linedefs.iter().copied().collect();
if selected.is_empty() {
return vec![];
}
let mut exact: Vec<(u32, bool, f32)> = Vec::new(); for sector in &map.sectors {
if sector.linedefs.len() != selected.len() {
continue;
}
if !sector.linedefs.iter().all(|id| selected.contains(id)) {
continue;
}
if !Self::sector_has_horizontal_loop(map, sector.id) {
continue;
}
let has_roof_feature = sector
.properties
.get_str_default("sector_feature", "None".to_string())
== "Roof";
exact.push((
sector.id,
has_roof_feature,
Self::sector_bbox_area(map, sector.id),
));
}
if !exact.is_empty() {
exact.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal))
});
return vec![exact[0].0];
}
let mut scored: Vec<(u32, bool, usize, usize, f32)> = Vec::new(); for sector in &map.sectors {
let total = sector.linedefs.len();
if total < 3 {
continue;
}
let hits = sector
.linedefs
.iter()
.filter(|id| selected.contains(id))
.count();
if hits >= 3 && Self::sector_has_horizontal_loop(map, sector.id) {
let area = Self::sector_bbox_area(map, sector.id);
let has_roof_feature = sector
.properties
.get_str_default("sector_feature", "None".to_string())
== "Roof";
scored.push((sector.id, has_roof_feature, hits, total, area));
}
}
if !scored.is_empty() {
scored.sort_by(|a, b| {
b.2.cmp(&a.2)
.then_with(|| b.1.cmp(&a.1))
.then_with(|| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal))
.then(a.3.cmp(&b.3))
});
let best_hits = scored[0].2;
let mut best: Option<(u32, bool, f32, usize)> = None; for (sector_id, has_roof_feature, hits, total, area) in scored {
if hits != best_hits {
continue;
}
match best {
None => best = Some((sector_id, has_roof_feature, area, total)),
Some((_id, best_roof, best_area, best_total)) => {
if (has_roof_feature && !best_roof)
|| (has_roof_feature == best_roof
&& (area > best_area || (area == best_area && total < best_total)))
{
best = Some((sector_id, has_roof_feature, area, total));
}
}
}
}
if let Some((sector_id, _best_roof, _area, _total)) = best {
return vec![sector_id];
}
}
let mut ids: BTreeSet<u32> = BTreeSet::new();
for linedef_id in &map.selected_linedefs {
if let Some(linedef) = map.find_linedef(*linedef_id) {
for sector_id in &linedef.sector_ids {
if Self::sector_has_horizontal_loop(map, *sector_id) {
ids.insert(*sector_id);
}
}
}
}
if ids.is_empty() {
vec![]
} else {
let mut best = 0u32;
let mut best_area = f32::NEG_INFINITY;
for id in ids {
let area = Self::sector_bbox_area(map, id);
if area > best_area {
best_area = area;
best = id;
}
}
vec![best]
}
}
fn create_sector_from_selected_linedefs(map: &mut Map) -> Option<u32> {
if map.selected_linedefs.len() < 3 {
return None;
}
let mut remaining: Vec<u32> = map.selected_linedefs.clone();
let first_id = *remaining.first()?;
let first = map.find_linedef(first_id)?;
let start_vertex = first.start_vertex;
let mut current_end = first.end_vertex;
let mut ordered = vec![first_id];
remaining.remove(0);
while !remaining.is_empty() {
let mut found_idx: Option<usize> = None;
for (idx, id) in remaining.iter().enumerate() {
if let Some(ld) = map.find_linedef(*id)
&& ld.start_vertex == current_end
{
found_idx = Some(idx);
current_end = ld.end_vertex;
ordered.push(*id);
break;
}
}
if let Some(idx) = found_idx {
remaining.remove(idx);
} else {
return None;
}
}
if current_end != start_vertex {
return None;
}
map.possible_polygon = ordered;
let sector_id = map.create_sector_from_polygon()?;
let mut surface = Surface::new(sector_id);
surface.calculate_geometry(map);
map.surfaces.insert(surface.id, surface);
Some(sector_id)
}
fn parse_tile_pixelsource(text: &str) -> Option<PixelSource> {
match Self::parse_tile_source(text) {
Some(Value::Source(source)) => Some(source),
_ => None,
}
}
}
impl Action for CreateRoof {
fn new() -> Self
where
Self: Sized,
{
let mut nodeui = TheNodeUI::default();
nodeui.add_item(TheNodeUIItem::OpenTree("roof".into()));
nodeui.add_item(TheNodeUIItem::Text(
"actionRoofName".into(),
"".into(),
"".into(),
"Roof".into(),
None,
false,
));
nodeui.add_item(TheNodeUIItem::Selector(
"actionRoofStyle".into(),
"".into(),
"".into(),
vec!["flat".into(), "pyramid".into(), "gable".into()],
1,
));
nodeui.add_item(TheNodeUIItem::FloatEditSlider(
"actionRoofHeight".into(),
"".into(),
"".into(),
1.0,
0.0..=16.0,
false,
));
nodeui.add_item(TheNodeUIItem::FloatEditSlider(
"actionRoofOverhang".into(),
"".into(),
"".into(),
0.0,
0.0..=4.0,
false,
));
nodeui.add_item(TheNodeUIItem::CloseTree);
nodeui.add_item(TheNodeUIItem::OpenTree("material".into()));
nodeui.add_item(TheNodeUIItem::Text(
"actionRoofTileId".into(),
"".into(),
"".into(),
"".into(),
None,
false,
));
nodeui.add_item(TheNodeUIItem::Text(
"actionRoofSideTileId".into(),
"".into(),
"".into(),
"".into(),
None,
false,
));
nodeui.add_item(TheNodeUIItem::CloseTree);
nodeui.add_item(TheNodeUIItem::Markdown("desc".into(), "".into()));
Self {
id: TheId::named_with_id(
"Create Roof",
Uuid::from_str(CREATE_ROOF_ACTION_ID).unwrap(),
),
nodeui,
}
}
fn id(&self) -> TheId {
self.id.clone()
}
fn info(&self) -> String {
"Configure a non-destructive roof on sectors touched by selected linedefs.".to_string()
}
fn role(&self) -> ActionRole {
ActionRole::Editor
}
fn is_applicable(&self, map: &Map, _ctx: &mut TheContext, server_ctx: &ServerContext) -> bool {
if server_ctx.editor_view_mode == EditorViewMode::D2 {
return false;
}
map.selected_sectors.is_empty() && !map.selected_linedefs.is_empty()
}
fn load_params(&mut self, map: &Map) {
let sector_ids = self.selected_roof_sector_ids(map);
let Some(sector_id) = sector_ids.first().copied() else {
return;
};
let Some(sector) = map.find_sector(sector_id) else {
return;
};
self.nodeui.set_text_value(
"actionRoofName",
sector
.properties
.get_str_default("roof_name", "Roof".to_string()),
);
self.nodeui.set_i32_value(
"actionRoofStyle",
sector
.properties
.get_int_default("roof_style", 1)
.clamp(0, 2),
);
self.nodeui.set_f32_value(
"actionRoofHeight",
sector.properties.get_float_default("roof_height", 1.0),
);
self.nodeui.set_f32_value(
"actionRoofOverhang",
sector.properties.get_float_default("roof_overhang", 0.0),
);
let tile_id_text = source_to_text(sector.properties.get("roof_tile_source"));
let side_tile_id_text = source_to_text(sector.properties.get("roof_side_source"));
self.nodeui.set_text_value("actionRoofTileId", tile_id_text);
self.nodeui
.set_text_value("actionRoofSideTileId", side_tile_id_text);
}
fn apply(
&self,
map: &mut Map,
_ui: &mut TheUI,
_ctx: &mut TheContext,
server_ctx: &mut ServerContext,
) -> Option<ProjectUndoAtom> {
let prev = map.clone();
let mut changed = false;
let mut sector_ids = self.selected_roof_sector_ids(map);
if sector_ids.is_empty() {
if let Some(created) = Self::create_sector_from_selected_linedefs(map) {
sector_ids = vec![created];
}
}
for sector_id in §or_ids {
changed |= self.apply_sector_roof(map, *sector_id);
}
if !map.selected_linedefs.is_empty() && !sector_ids.is_empty() {
let selected_set: HashSet<u32> = sector_ids.iter().copied().collect();
let mut touched: BTreeSet<u32> = BTreeSet::new();
for linedef_id in &map.selected_linedefs {
if let Some(linedef) = map.find_linedef(*linedef_id) {
for sid in &linedef.sector_ids {
touched.insert(*sid);
}
}
}
for sid in touched {
if selected_set.contains(&sid) {
continue;
}
if Self::clear_sector_roof(map, sid) {
changed = true;
}
}
}
if changed {
Some(ProjectUndoAtom::MapEdit(
server_ctx.pc,
Box::new(prev),
Box::new(map.clone()),
))
} else {
None
}
}
fn params(&self) -> TheNodeUI {
self.nodeui.clone()
}
fn hud_material_slots(
&self,
_map: &Map,
_server_ctx: &ServerContext,
) -> Option<Vec<ActionMaterialSlot>> {
let top = self
.nodeui
.get_text_value("actionRoofTileId")
.unwrap_or_default();
let side = self
.nodeui
.get_text_value("actionRoofSideTileId")
.unwrap_or_default();
Some(vec![
ActionMaterialSlot {
label: "TOP".to_string(),
source: Self::parse_tile_pixelsource(&top),
},
ActionMaterialSlot {
label: "SIDE".to_string(),
source: Self::parse_tile_pixelsource(&side),
},
])
}
fn set_hud_material_from_tile(
&mut self,
_map: &Map,
_server_ctx: &ServerContext,
slot_index: i32,
tile_id: Uuid,
) -> bool {
match slot_index {
0 => self
.nodeui
.set_text_value("actionRoofTileId", tile_id.to_string()),
1 => self
.nodeui
.set_text_value("actionRoofSideTileId", tile_id.to_string()),
_ => return false,
}
true
}
fn clear_hud_material_slot(
&mut self,
_map: &Map,
_server_ctx: &ServerContext,
slot_index: i32,
) -> bool {
match slot_index {
0 => self
.nodeui
.set_text_value("actionRoofTileId", String::new()),
1 => self
.nodeui
.set_text_value("actionRoofSideTileId", String::new()),
_ => return false,
}
true
}
fn handle_event(
&mut self,
event: &TheEvent,
_project: &mut Project,
_ui: &mut TheUI,
_ctx: &mut TheContext,
_server_ctx: &mut ServerContext,
) -> bool {
self.nodeui.handle_event(event)
}
}