1use crate::prelude::*;
2use rusterix::PixelSource;
3use rusterix::Surface;
4use std::collections::{BTreeSet, HashSet};
5use std::str::FromStr;
6
7pub const CREATE_ROOF_ACTION_ID: &str = "9f4b34ad-2f43-4c31-9f41-9f5664c6d5e3";
8
9pub struct CreateRoof {
10 id: TheId,
11 nodeui: TheNodeUI,
12}
13
14impl CreateRoof {
15 fn parse_tile_source(text: &str) -> Option<Value> {
16 let id = Uuid::parse_str(text.trim()).ok()?;
17 Some(Value::Source(PixelSource::TileId(id)))
18 }
19
20 fn apply_sector_roof(&self, map: &mut Map, sector_id: u32) -> bool {
21 let roof_name = self
22 .nodeui
23 .get_text_value("actionRoofName")
24 .unwrap_or_else(|| "Roof".to_string());
25 let roof_style = self
26 .nodeui
27 .get_i32_value("actionRoofStyle")
28 .unwrap_or(1)
29 .clamp(0, 2);
30 let roof_height = self
31 .nodeui
32 .get_f32_value("actionRoofHeight")
33 .unwrap_or(1.0)
34 .max(0.0);
35 let roof_overhang = self
36 .nodeui
37 .get_f32_value("actionRoofOverhang")
38 .unwrap_or(0.0)
39 .max(0.0);
40
41 let tile_id_text = self
42 .nodeui
43 .get_text_value("actionRoofTileId")
44 .unwrap_or_default();
45 let side_tile_id_text = self
46 .nodeui
47 .get_text_value("actionRoofSideTileId")
48 .unwrap_or_default();
49
50 let Some(sector) = map.find_sector_mut(sector_id) else {
51 return false;
52 };
53
54 if roof_height <= 0.0 {
55 sector
56 .properties
57 .set("sector_feature", Value::Str("None".to_string()));
58 sector.properties.remove("roof_name");
59 sector.properties.remove("roof_style");
60 sector.properties.remove("roof_height");
61 sector.properties.remove("roof_overhang");
62 sector.properties.remove("roof_tile_source");
63 sector.properties.remove("roof_side_source");
64 return true;
65 }
66
67 sector
68 .properties
69 .set("sector_feature", Value::Str("Roof".to_string()));
70 sector.properties.set("roof_name", Value::Str(roof_name));
71 sector.properties.set("roof_style", Value::Int(roof_style));
72 sector
73 .properties
74 .set("roof_height", Value::Float(roof_height));
75 sector
76 .properties
77 .set("roof_overhang", Value::Float(roof_overhang));
78
79 if let Some(src) = Self::parse_tile_source(&tile_id_text) {
80 sector.properties.set("roof_tile_source", src);
81 } else {
82 sector.properties.remove("roof_tile_source");
83 }
84 if let Some(src) = Self::parse_tile_source(&side_tile_id_text) {
85 sector.properties.set("roof_side_source", src);
86 } else {
87 sector.properties.remove("roof_side_source");
88 }
89
90 true
91 }
92
93 fn clear_sector_roof(map: &mut Map, sector_id: u32) -> bool {
94 let Some(sector) = map.find_sector_mut(sector_id) else {
95 return false;
96 };
97 let had_roof = sector
98 .properties
99 .get_str_default("sector_feature", "None".to_string())
100 == "Roof";
101 if had_roof {
102 sector
103 .properties
104 .set("sector_feature", Value::Str("None".to_string()));
105 sector.properties.remove("roof_name");
106 sector.properties.remove("roof_style");
107 sector.properties.remove("roof_height");
108 sector.properties.remove("roof_overhang");
109 sector.properties.remove("roof_tile_source");
110 sector.properties.remove("roof_side_source");
111 }
112 had_roof
113 }
114
115 fn sector_has_horizontal_loop(map: &Map, sector_id: u32) -> bool {
116 for surface in map.surfaces.values() {
117 if surface.sector_id != sector_id {
118 continue;
119 }
120 if surface.plane.normal.y.abs() <= 0.7 {
121 continue;
122 }
123 if let Some(loop_uv) = surface.sector_loop_uv(map)
124 && loop_uv.len() >= 3
125 {
126 return true;
127 }
128 }
129 false
130 }
131
132 fn sector_bbox_area(map: &Map, sector_id: u32) -> f32 {
133 if let Some(sector) = map.find_sector(sector_id) {
134 let bbox = sector.bounding_box(map);
135 let sx = (bbox.max.x - bbox.min.x).abs();
136 let sy = (bbox.max.y - bbox.min.y).abs();
137 sx * sy
138 } else {
139 0.0
140 }
141 }
142
143 fn selected_roof_sector_ids(&self, map: &Map) -> Vec<u32> {
144 let selected: HashSet<u32> = map.selected_linedefs.iter().copied().collect();
145 if selected.is_empty() {
146 return vec![];
147 }
148
149 let mut exact: Vec<(u32, bool, f32)> = Vec::new(); for sector in &map.sectors {
153 if sector.linedefs.len() != selected.len() {
154 continue;
155 }
156 if !sector.linedefs.iter().all(|id| selected.contains(id)) {
157 continue;
158 }
159 if !Self::sector_has_horizontal_loop(map, sector.id) {
160 continue;
161 }
162 let has_roof_feature = sector
163 .properties
164 .get_str_default("sector_feature", "None".to_string())
165 == "Roof";
166 exact.push((
167 sector.id,
168 has_roof_feature,
169 Self::sector_bbox_area(map, sector.id),
170 ));
171 }
172 if !exact.is_empty() {
173 exact.sort_by(|a, b| {
174 b.1.cmp(&a.1)
175 .then_with(|| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal))
176 });
177 return vec![exact[0].0];
178 }
179
180 let mut scored: Vec<(u32, bool, usize, usize, f32)> = Vec::new(); for sector in &map.sectors {
183 let total = sector.linedefs.len();
184 if total < 3 {
185 continue;
186 }
187 let hits = sector
188 .linedefs
189 .iter()
190 .filter(|id| selected.contains(id))
191 .count();
192 if hits >= 3 && Self::sector_has_horizontal_loop(map, sector.id) {
193 let area = Self::sector_bbox_area(map, sector.id);
194 let has_roof_feature = sector
195 .properties
196 .get_str_default("sector_feature", "None".to_string())
197 == "Roof";
198 scored.push((sector.id, has_roof_feature, hits, total, area));
199 }
200 }
201
202 if !scored.is_empty() {
203 scored.sort_by(|a, b| {
205 b.2.cmp(&a.2)
206 .then_with(|| b.1.cmp(&a.1))
207 .then_with(|| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal))
208 .then(a.3.cmp(&b.3))
209 });
210 let best_hits = scored[0].2;
211 let mut best: Option<(u32, bool, f32, usize)> = None; for (sector_id, has_roof_feature, hits, total, area) in scored {
213 if hits != best_hits {
214 continue;
215 }
216 match best {
217 None => best = Some((sector_id, has_roof_feature, area, total)),
218 Some((_id, best_roof, best_area, best_total)) => {
219 if (has_roof_feature && !best_roof)
220 || (has_roof_feature == best_roof
221 && (area > best_area || (area == best_area && total < best_total)))
222 {
223 best = Some((sector_id, has_roof_feature, area, total));
224 }
225 }
226 }
227 }
228 if let Some((sector_id, _best_roof, _area, _total)) = best {
229 return vec![sector_id];
230 }
231 }
232
233 let mut ids: BTreeSet<u32> = BTreeSet::new();
235 for linedef_id in &map.selected_linedefs {
236 if let Some(linedef) = map.find_linedef(*linedef_id) {
237 for sector_id in &linedef.sector_ids {
238 if Self::sector_has_horizontal_loop(map, *sector_id) {
239 ids.insert(*sector_id);
240 }
241 }
242 }
243 }
244 if ids.is_empty() {
245 vec![]
246 } else {
247 let mut best = 0u32;
249 let mut best_area = f32::NEG_INFINITY;
250 for id in ids {
251 let area = Self::sector_bbox_area(map, id);
252 if area > best_area {
253 best_area = area;
254 best = id;
255 }
256 }
257 vec![best]
258 }
259 }
260
261 fn create_sector_from_selected_linedefs(map: &mut Map) -> Option<u32> {
262 if map.selected_linedefs.len() < 3 {
263 return None;
264 }
265
266 let mut remaining: Vec<u32> = map.selected_linedefs.clone();
268 let first_id = *remaining.first()?;
269 let first = map.find_linedef(first_id)?;
270 let start_vertex = first.start_vertex;
271 let mut current_end = first.end_vertex;
272 let mut ordered = vec![first_id];
273 remaining.remove(0);
274
275 while !remaining.is_empty() {
276 let mut found_idx: Option<usize> = None;
277 for (idx, id) in remaining.iter().enumerate() {
278 if let Some(ld) = map.find_linedef(*id)
279 && ld.start_vertex == current_end
280 {
281 found_idx = Some(idx);
282 current_end = ld.end_vertex;
283 ordered.push(*id);
284 break;
285 }
286 }
287 if let Some(idx) = found_idx {
288 remaining.remove(idx);
289 } else {
290 return None;
292 }
293 }
294
295 if current_end != start_vertex {
296 return None;
297 }
298
299 map.possible_polygon = ordered;
300 let sector_id = map.create_sector_from_polygon()?;
301
302 let mut surface = Surface::new(sector_id);
305 surface.calculate_geometry(map);
306 map.surfaces.insert(surface.id, surface);
307
308 Some(sector_id)
309 }
310}
311
312impl Action for CreateRoof {
313 fn new() -> Self
314 where
315 Self: Sized,
316 {
317 let mut nodeui = TheNodeUI::default();
318
319 nodeui.add_item(TheNodeUIItem::OpenTree("roof".into()));
320 nodeui.add_item(TheNodeUIItem::Text(
321 "actionRoofName".into(),
322 "".into(),
323 "".into(),
324 "Roof".into(),
325 None,
326 false,
327 ));
328 nodeui.add_item(TheNodeUIItem::Selector(
329 "actionRoofStyle".into(),
330 "".into(),
331 "".into(),
332 vec!["flat".into(), "pyramid".into(), "gable".into()],
333 1,
334 ));
335 nodeui.add_item(TheNodeUIItem::FloatEditSlider(
336 "actionRoofHeight".into(),
337 "".into(),
338 "".into(),
339 1.0,
340 0.0..=16.0,
341 false,
342 ));
343 nodeui.add_item(TheNodeUIItem::FloatEditSlider(
344 "actionRoofOverhang".into(),
345 "".into(),
346 "".into(),
347 0.0,
348 0.0..=4.0,
349 false,
350 ));
351 nodeui.add_item(TheNodeUIItem::CloseTree);
352
353 nodeui.add_item(TheNodeUIItem::OpenTree("material".into()));
354 nodeui.add_item(TheNodeUIItem::Text(
355 "actionRoofTileId".into(),
356 "".into(),
357 "".into(),
358 "".into(),
359 None,
360 false,
361 ));
362 nodeui.add_item(TheNodeUIItem::Text(
363 "actionRoofSideTileId".into(),
364 "".into(),
365 "".into(),
366 "".into(),
367 None,
368 false,
369 ));
370 nodeui.add_item(TheNodeUIItem::CloseTree);
371
372 nodeui.add_item(TheNodeUIItem::Markdown("desc".into(), "".into()));
373
374 Self {
375 id: TheId::named_with_id(
376 "Create Roof",
377 Uuid::from_str(CREATE_ROOF_ACTION_ID).unwrap(),
378 ),
379 nodeui,
380 }
381 }
382
383 fn id(&self) -> TheId {
384 self.id.clone()
385 }
386
387 fn info(&self) -> String {
388 "Configure a non-destructive roof on sectors touched by selected linedefs.".to_string()
389 }
390
391 fn role(&self) -> ActionRole {
392 ActionRole::Editor
393 }
394
395 fn is_applicable(&self, map: &Map, _ctx: &mut TheContext, server_ctx: &ServerContext) -> bool {
396 if server_ctx.editor_view_mode == EditorViewMode::D2 {
397 return false;
398 }
399 map.selected_sectors.is_empty() && !map.selected_linedefs.is_empty()
400 }
401
402 fn load_params(&mut self, map: &Map) {
403 let sector_ids = self.selected_roof_sector_ids(map);
404 let Some(sector_id) = sector_ids.first().copied() else {
405 return;
406 };
407 let Some(sector) = map.find_sector(sector_id) else {
408 return;
409 };
410
411 self.nodeui.set_text_value(
412 "actionRoofName",
413 sector
414 .properties
415 .get_str_default("roof_name", "Roof".to_string()),
416 );
417 self.nodeui.set_i32_value(
418 "actionRoofStyle",
419 sector
420 .properties
421 .get_int_default("roof_style", 1)
422 .clamp(0, 2),
423 );
424 self.nodeui.set_f32_value(
425 "actionRoofHeight",
426 sector.properties.get_float_default("roof_height", 1.0),
427 );
428 self.nodeui.set_f32_value(
429 "actionRoofOverhang",
430 sector.properties.get_float_default("roof_overhang", 0.0),
431 );
432
433 let tile_id_text = match sector.properties.get("roof_tile_source") {
434 Some(Value::Source(PixelSource::TileId(id))) => id.to_string(),
435 _ => String::new(),
436 };
437 let side_tile_id_text = match sector.properties.get("roof_side_source") {
438 Some(Value::Source(PixelSource::TileId(id))) => id.to_string(),
439 _ => String::new(),
440 };
441 self.nodeui.set_text_value("actionRoofTileId", tile_id_text);
442 self.nodeui
443 .set_text_value("actionRoofSideTileId", side_tile_id_text);
444 }
445
446 fn apply(
447 &self,
448 map: &mut Map,
449 _ui: &mut TheUI,
450 _ctx: &mut TheContext,
451 server_ctx: &mut ServerContext,
452 ) -> Option<ProjectUndoAtom> {
453 let prev = map.clone();
454 let mut changed = false;
455
456 let mut sector_ids = self.selected_roof_sector_ids(map);
457 if sector_ids.is_empty() {
458 if let Some(created) = Self::create_sector_from_selected_linedefs(map) {
459 sector_ids = vec![created];
460 }
461 }
462 for sector_id in §or_ids {
463 changed |= self.apply_sector_roof(map, *sector_id);
464 }
465
466 if !map.selected_linedefs.is_empty() && !sector_ids.is_empty() {
469 let selected_set: HashSet<u32> = sector_ids.iter().copied().collect();
470 let mut touched: BTreeSet<u32> = BTreeSet::new();
471 for linedef_id in &map.selected_linedefs {
472 if let Some(linedef) = map.find_linedef(*linedef_id) {
473 for sid in &linedef.sector_ids {
474 touched.insert(*sid);
475 }
476 }
477 }
478 for sid in touched {
479 if selected_set.contains(&sid) {
480 continue;
481 }
482 if Self::clear_sector_roof(map, sid) {
483 changed = true;
484 }
485 }
486 }
487
488 if changed {
489 Some(ProjectUndoAtom::MapEdit(
490 server_ctx.pc,
491 Box::new(prev),
492 Box::new(map.clone()),
493 ))
494 } else {
495 None
496 }
497 }
498
499 fn params(&self) -> TheNodeUI {
500 self.nodeui.clone()
501 }
502
503 fn handle_event(
504 &mut self,
505 event: &TheEvent,
506 _project: &mut Project,
507 _ui: &mut TheUI,
508 _ctx: &mut TheContext,
509 _server_ctx: &mut ServerContext,
510 ) -> bool {
511 self.nodeui.handle_event(event)
512 }
513}