Skip to main content

rustapi/actions/
create_roof.rs

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        // Exact match first: when user selects the enclosing linedef loop,
150        // prefer the sector that is built from exactly that loop.
151        let mut exact: Vec<(u32, bool, f32)> = Vec::new(); // (sector_id, has_roof_feature, area)
152        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        // Prefer sectors that are clearly enclosed by the selected linedef set.
181        let mut scored: Vec<(u32, bool, usize, usize, f32)> = Vec::new(); // (sector_id, has_roof_feature, hits, total, area)
182        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            // Highest linedef hit-count first, then larger area, then fewer edges.
204            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; // (sector_id, has_roof_feature, area, total)
212            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        // Fallback: direct adjacency from selected linedefs.
234        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            // Keep fallback deterministic: prefer largest area horizontal sector.
248            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        // Build a directed chain from selected linedefs using their existing winding.
267        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                // Could not build a directed closed chain.
291                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        // Ensure the newly enclosed sector has a generated surface so roof builder
303        // can find a horizontal base loop immediately.
304        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 &sector_ids {
463            changed |= self.apply_sector_roof(map, *sector_id);
464        }
465
466        // Cleanup stale roof features on sibling sectors touched by the same selected linedefs.
467        // This avoids stacked/mixed roofs after earlier mis-targeted applies.
468        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}