use crate::prelude::*;
use rusterix::Surface;
use rusterix::builderpreview::{BuilderPreviewOptions, PreviewVariants, render_builder_preview};
use scenevm::GeoId;
use shared::buildergraph::{BuilderDocument, BuilderGraph};
const BUILDER_SCRIPT_EDITOR: &str = "Builder Script Editor";
const BUILDER_SCRIPT_PREVIEW: &str = "Builder Script Preview";
const BUILDER_SCRIPT_TITLE: &str = "Builder Script Title";
const BUILDER_EDITOR_GRAPH_BUTTON: &str = "Builder Editor Graph";
const BUILDER_EDITOR_RESET_BUTTON: &str = "Builder Editor Reset";
pub struct BuilderEditorDock {
source: String,
active_builder_id: Option<Uuid>,
}
impl BuilderEditorDock {
fn default_source() -> String {
BuilderGraph::preset_table_script_named("Table".to_string())
}
fn linedef_builder_host_sector_id(map: &Map, linedef_id: u32) -> Option<u32> {
let linedef = map.find_linedef(linedef_id)?;
let host_sector = linedef
.properties
.get_int("host_sector")
.map(|id| id as u32);
host_sector
.filter(|sector_id| map.find_sector(*sector_id).is_some())
.or_else(|| linedef.sector_ids.first().copied())
}
fn linedef_builder_stored_outward(map: &Map, linedef_id: u32) -> Option<Vec3<f32>> {
let linedef = map.find_linedef(linedef_id)?;
let outward = Vec3::new(
linedef.properties.get_float("host_outward_x")?,
linedef.properties.get_float("host_outward_y")?,
linedef.properties.get_float("host_outward_z")?,
);
outward.try_normalized()
}
fn linedef_builder_stored_face_origin(map: &Map, linedef_id: u32) -> Option<Vec3<f32>> {
let linedef = map.find_linedef(linedef_id)?;
Some(Vec3::new(
linedef.properties.get_float("host_surface_origin_x")?,
linedef.properties.get_float("host_surface_origin_y")?,
linedef.properties.get_float("host_surface_origin_z")?,
))
}
fn builder_surface_for_sector(map: &Map, sector_id: u32) -> Option<Surface> {
if let Some(surface) = map.get_surface_for_sector_id(sector_id) {
return Some(surface.clone());
}
let mut surface = Surface::new(sector_id);
surface.calculate_geometry(map);
surface.is_valid().then_some(surface)
}
fn resolve_builder_surface_side(
signed_dist: f32,
surface_normal: Vec3<f32>,
hover_ray_dir: Option<Vec3<f32>>,
) -> bool {
if signed_dist.abs() > 0.01 {
signed_dist >= 0.0
} else if let Some(ray_dir) = hover_ray_dir {
surface_normal.dot(-ray_dir) >= 0.0
} else {
true
}
}
fn linedef_builder_face_origin(
map: &Map,
server_ctx: &ServerContext,
linedef_id: u32,
) -> Option<Vec3<f32>> {
if let Some(face_origin) = Self::linedef_builder_stored_face_origin(map, linedef_id) {
return Some(face_origin);
}
let surface = Self::linedef_builder_surface(map, server_ctx, linedef_id)?;
let outward = Self::linedef_builder_outward(map, server_ctx, linedef_id)?;
let normal = surface.plane.normal.try_normalized()?;
let (front_offset, back_offset) = surface.extrusion.offsets();
let selected_offset = if outward.dot(normal) >= 0.0 {
front_offset.max(back_offset)
} else {
front_offset.min(back_offset)
};
Some(surface.plane.origin + normal * selected_offset)
}
fn linedef_builder_surface(
map: &Map,
server_ctx: &ServerContext,
linedef_id: u32,
) -> Option<Surface> {
let linedef = map.find_linedef(linedef_id)?;
let host_sector_id = Self::linedef_builder_host_sector_id(map, linedef_id);
let preferred = server_ctx
.active_detail_surface
.as_ref()
.or(server_ctx.hover_surface.as_ref())
.or(server_ctx.editing_surface.as_ref());
if let Some(surface) = preferred
&& host_sector_id
.map(|sector_id| surface.sector_id == sector_id)
.unwrap_or_else(|| linedef.sector_ids.contains(&surface.sector_id))
&& surface.plane.normal.y.abs() <= 0.25
{
return Some(surface.clone());
}
if let Some(host_sector_id) = host_sector_id
&& let Some(surface) = Self::builder_surface_for_sector(map, host_sector_id)
&& surface.plane.normal.y.abs() <= 0.25
{
return Some(surface);
}
let hit_pos = server_ctx
.editing_surface_hit_pos
.or(server_ctx.hover_cursor_3d)
.unwrap_or(server_ctx.geo_hit_pos);
let ray_dir = server_ctx
.hover_ray_dir_3d
.and_then(|dir| dir.try_normalized());
let mut best_surface: Option<(Surface, f32)> = None;
for surface in map.surfaces.values() {
let surface_matches_host = host_sector_id
.map(|sector_id| surface.sector_id == sector_id)
.unwrap_or_else(|| linedef.sector_ids.contains(&surface.sector_id));
if !surface_matches_host || surface.plane.normal.y.abs() > 0.25 {
continue;
}
let Some(normal) = surface.plane.normal.try_normalized() else {
continue;
};
if let Some(ray_dir) = ray_dir
&& normal.dot(ray_dir) >= -1e-4
{
continue;
}
let dist = (hit_pos - surface.plane.origin).dot(normal).abs();
if best_surface
.as_ref()
.map(|(_, best_dist)| dist < *best_dist)
.unwrap_or(true)
{
best_surface = Some((surface.clone(), dist));
}
}
best_surface.map(|(surface, _)| surface)
}
fn linedef_builder_outward(
map: &Map,
server_ctx: &ServerContext,
linedef_id: u32,
) -> Option<Vec3<f32>> {
if let Some(outward) = Self::linedef_builder_stored_outward(map, linedef_id) {
return Some(outward);
}
let hit = server_ctx.hover_cursor_3d.unwrap_or(server_ctx.geo_hit_pos);
let surface = Self::linedef_builder_surface(map, server_ctx, linedef_id)?;
let normal = surface.plane.normal.try_normalized()?;
let signed_dist = (hit - surface.plane.origin).dot(normal);
let grow_positive = Self::resolve_builder_surface_side(
signed_dist,
normal,
server_ctx
.hover_ray_dir_3d
.and_then(|dir| dir.try_normalized()),
);
Some(if grow_positive { normal } else { -normal })
}
fn linedef_builder_wall_side(map: &Map, server_ctx: &ServerContext, linedef_id: u32) -> f32 {
if let Some(linedef) = map.find_linedef(linedef_id) {
let hit_pos = server_ctx
.editing_surface_hit_pos
.or(server_ctx.hover_cursor_3d)
.unwrap_or(server_ctx.geo_hit_pos);
if matches!(server_ctx.geo_hit, Some(GeoId::Linedef(id)) if id == linedef_id)
&& let Some(dist) = linedef.signed_distance(map, Vec2::new(hit_pos.x, hit_pos.z))
&& dist.abs() > 1e-5
{
return if dist >= 0.0 { -1.0 } else { 1.0 };
}
let preferred_sector = map
.selected_sectors
.iter()
.copied()
.find(|sid| linedef.sector_ids.contains(sid))
.or_else(|| Self::linedef_builder_host_sector_id(map, linedef_id));
if let Some(sector_id) = preferred_sector
&& let Some(sector) = map.find_sector(sector_id)
&& let Some(center) = sector.center(map)
&& let Some(dist) = linedef.signed_distance(map, center)
&& dist.abs() > 1e-5
{
return if dist >= 0.0 { 1.0 } else { -1.0 };
}
}
1.0
}
fn load_state_from_project(&mut self, project: &Project, server_ctx: &ServerContext) {
self.active_builder_id = server_ctx.curr_builder_graph_id;
self.source = self
.active_builder_id
.and_then(|builder_id| project.builder_graphs.get(&builder_id))
.map(|asset| asset.graph_data.clone())
.unwrap_or_else(Self::default_source);
}
fn sync_editor(&self, ui: &mut TheUI, ctx: &mut TheContext) {
ui.set_widget_value(
BUILDER_SCRIPT_EDITOR,
ctx,
TheValue::Text(self.source.clone()),
);
}
fn refresh_ui(
&self,
ui: &mut TheUI,
ctx: &mut TheContext,
project: &Project,
server_ctx: &ServerContext,
) {
self.sync_editor(ui, ctx);
self.render_preview(ui, ctx);
self.sync_title(ui, ctx, project, server_ctx);
}
fn sync_title(
&self,
ui: &mut TheUI,
ctx: &mut TheContext,
project: &Project,
server_ctx: &ServerContext,
) {
let title = server_ctx
.curr_builder_graph_id
.and_then(|id| project.builder_graphs.get(&id))
.map(|asset| asset.graph_name.clone())
.unwrap_or_else(|| "Builder Script".to_string());
ui.set_widget_value(BUILDER_SCRIPT_TITLE, ctx, TheValue::Text(title));
}
fn render_preview(&self, ui: &mut TheUI, _ctx: &mut TheContext) {
let mut buffer = TheRGBABuffer::new(TheDim::sized(384, 384));
for pixel in buffer.pixels_mut().chunks_exact_mut(4) {
pixel.copy_from_slice(&[30, 32, 36, 255]);
}
if let Ok(document) = BuilderDocument::from_text(&self.source)
&& let Ok(assembly) = document.evaluate()
{
let preview = render_builder_preview(
&assembly,
document.output_spec(),
&document.preview_host(),
BuilderPreviewOptions {
size: 640,
variants: PreviewVariants::Single,
..Default::default()
},
);
if let Ok(preview) = preview {
buffer =
TheRGBABuffer::new(TheDim::sized(preview.width as i32, preview.height as i32));
buffer.pixels_mut().copy_from_slice(&preview.pixels);
}
}
if let Some(render_view) = ui.get_render_view(BUILDER_SCRIPT_PREVIEW) {
*render_view.render_buffer_mut() = buffer;
}
}
fn save_state_to_project(
&self,
project: &mut Project,
server_ctx: &ServerContext,
ctx: &mut TheContext,
) {
let Some(builder_id) = self.active_builder_id.or(server_ctx.curr_builder_graph_id) else {
return;
};
let parsed = BuilderDocument::from_text(&self.source).ok();
let graph_name = parsed
.as_ref()
.map(|doc| doc.name().to_string())
.or_else(|| {
project
.builder_graphs
.get(&builder_id)
.map(|asset| asset.graph_name.clone())
})
.unwrap_or_else(|| "Builder Script".to_string());
let spec = parsed.as_ref().map(|doc| doc.output_spec());
if let Some(asset) = project.builder_graphs.get_mut(&builder_id) {
asset.graph_name = graph_name.clone();
asset.graph_data = self.source.clone();
}
if let Some(map) = project.get_map_mut(server_ctx) {
if let Some(spec) = spec {
for sector_id in map.selected_sectors.clone() {
if let Some(sector) = map.find_sector_mut(sector_id) {
let matches_builder = matches!(
sector.properties.get("builder_graph_id"),
Some(Value::Id(id)) if *id == builder_id
);
if matches_builder {
sector
.properties
.set("builder_graph_name", Value::Str(graph_name.clone()));
sector
.properties
.set("builder_graph_data", Value::Str(self.source.clone()));
sector
.properties
.set("builder_graph_target", Value::Str("sector".to_string()));
sector
.properties
.set("builder_surface_mode", Value::Str("overlay".to_string()));
sector
.properties
.set("builder_hide_host", Value::Bool(true));
sector
.properties
.set("builder_graph_host_refs", Value::Int(spec.host_refs as i32));
}
}
if map.get_surface_for_sector_id(sector_id).is_none() {
let mut surface = Surface::new(sector_id);
surface.calculate_geometry(map);
map.surfaces.insert(surface.id, surface);
}
}
for vertex_id in map.selected_vertices.clone() {
if let Some(vertex) = map.find_vertex_mut(vertex_id) {
let matches_builder = matches!(
vertex.properties.get("builder_graph_id"),
Some(Value::Id(id)) if *id == builder_id
);
if matches_builder {
vertex
.properties
.set("builder_graph_name", Value::Str(graph_name.clone()));
vertex
.properties
.set("builder_graph_data", Value::Str(self.source.clone()));
vertex.properties.set(
"builder_graph_target",
Value::Str("vertex_pair".to_string()),
);
vertex
.properties
.set("builder_graph_host_refs", Value::Int(spec.host_refs as i32));
}
}
}
for linedef_id in map.selected_linedefs.clone() {
let wall_side = Self::linedef_builder_wall_side(map, server_ctx, linedef_id);
let wall_outward = Self::linedef_builder_outward(map, server_ctx, linedef_id);
let wall_face_origin =
Self::linedef_builder_face_origin(map, server_ctx, linedef_id);
if let Some(linedef) = map.find_linedef_mut(linedef_id) {
let matches_builder = matches!(
linedef.properties.get("builder_graph_id"),
Some(Value::Id(id)) if *id == builder_id
);
if matches_builder {
linedef
.properties
.set("builder_graph_name", Value::Str(graph_name.clone()));
linedef
.properties
.set("builder_graph_data", Value::Str(self.source.clone()));
linedef
.properties
.set("builder_graph_target", Value::Str("linedef".to_string()));
linedef
.properties
.set("builder_graph_host_refs", Value::Int(spec.host_refs as i32));
linedef
.properties
.set("builder_graph_wall_side", Value::Float(wall_side));
if let Some(outward) = wall_outward {
linedef
.properties
.set("builder_graph_outward_x", Value::Float(outward.x));
linedef
.properties
.set("builder_graph_outward_y", Value::Float(outward.y));
linedef
.properties
.set("builder_graph_outward_z", Value::Float(outward.z));
}
if let Some(face_origin) = wall_face_origin {
linedef.properties.set(
"builder_graph_surface_origin_x",
Value::Float(face_origin.x),
);
linedef.properties.set(
"builder_graph_surface_origin_y",
Value::Float(face_origin.y),
);
linedef.properties.set(
"builder_graph_surface_origin_z",
Value::Float(face_origin.z),
);
}
linedef.properties.remove("builder_graph_face_offset");
}
}
}
} else {
for sector_id in map.selected_sectors.clone() {
if let Some(sector) = map.find_sector_mut(sector_id)
&& matches!(
sector.properties.get("builder_graph_id"),
Some(Value::Id(id)) if *id == builder_id
)
{
sector
.properties
.set("builder_graph_data", Value::Str(self.source.clone()));
}
}
for vertex_id in map.selected_vertices.clone() {
if let Some(vertex) = map.find_vertex_mut(vertex_id)
&& matches!(
vertex.properties.get("builder_graph_id"),
Some(Value::Id(id)) if *id == builder_id
)
{
vertex
.properties
.set("builder_graph_data", Value::Str(self.source.clone()));
}
}
for linedef_id in map.selected_linedefs.clone() {
if let Some(linedef) = map.find_linedef_mut(linedef_id)
&& matches!(
linedef.properties.get("builder_graph_id"),
Some(Value::Id(id)) if *id == builder_id
)
{
linedef
.properties
.set("builder_graph_data", Value::Str(self.source.clone()));
}
}
}
}
ctx.ui.send(TheEvent::Custom(
TheId::named("Builder Graph Updated"),
TheValue::Id(builder_id),
));
}
fn import_graph_file(&mut self, path: &std::path::Path) -> Result<(), String> {
let source = std::fs::read_to_string(path)
.map_err(|err| format!("failed to read {}: {err}", path.to_string_lossy()))?;
BuilderDocument::from_text(&source)
.map_err(|err| format!("failed to parse {}: {err}", path.to_string_lossy()))?;
self.source = source;
Ok(())
}
fn export_graph_file(&self, path: &std::path::Path) -> Result<std::path::PathBuf, String> {
let mut output_path = path.to_path_buf();
if output_path.extension().is_none() {
output_path.set_extension("buildergraph");
}
std::fs::write(&output_path, &self.source)
.map_err(|err| format!("failed to write {}: {err}", output_path.to_string_lossy()))?;
Ok(output_path)
}
}
impl Dock for BuilderEditorDock {
fn new() -> Self
where
Self: Sized,
{
Self {
source: Self::default_source(),
active_builder_id: None,
}
}
fn setup(&mut self, _ctx: &mut TheContext) -> TheCanvas {
let mut canvas = TheCanvas::new();
let mut toolbar_canvas = TheCanvas::default();
toolbar_canvas.set_widget(TheTraybar::new(TheId::empty()));
let mut toolbar_hlayout = TheHLayout::new(TheId::empty());
toolbar_hlayout.set_background_color(None);
toolbar_hlayout.set_margin(Vec4::new(10, 1, 5, 1));
toolbar_hlayout.set_padding(3);
toolbar_hlayout.set_reverse_index(Some(2));
let mut title = TheText::new(TheId::named(BUILDER_SCRIPT_TITLE));
title.set_text("Builder Script".to_string());
title.set_text_size(12.0);
toolbar_hlayout.add_widget(Box::new(title));
let spacer = TheSpacer::new(TheId::empty());
toolbar_hlayout.add_widget(Box::new(spacer));
let mut graph_button = TheTraybarButton::new(TheId::named(BUILDER_EDITOR_GRAPH_BUTTON));
graph_button.set_text("Graph".to_string());
graph_button.set_status_text("Import or export the current builder script.");
let mut graph_menu = TheContextMenu::default();
graph_menu.add(TheContextMenuItem::new(
"Import Script...".to_string(),
TheId::named("Builder Import Graph"),
));
graph_menu.add(TheContextMenuItem::new(
"Export Script...".to_string(),
TheId::named("Builder Export Graph"),
));
graph_button.set_context_menu(Some(graph_menu));
toolbar_hlayout.add_widget(Box::new(graph_button));
let mut reset_button = TheTraybarButton::new(TheId::named(BUILDER_EDITOR_RESET_BUTTON));
reset_button.set_text("Reset".to_string());
reset_button.set_status_text("Reset to the current default builder script preset.");
toolbar_hlayout.add_widget(Box::new(reset_button));
toolbar_canvas.set_layout(toolbar_hlayout);
canvas.set_top(toolbar_canvas);
let mut center = TheCanvas::new();
let mut script_canvas = TheCanvas::new();
let mut textedit = TheTextAreaEdit::new(TheId::named(BUILDER_SCRIPT_EDITOR));
if let Some(bytes) = crate::Embedded::get("parser/buildergraph.sublime-syntax")
&& let Ok(source) = std::str::from_utf8(bytes.data.as_ref())
{
textedit.add_syntax_from_string(source);
textedit.set_code_type("BuilderGraph");
}
if let Some(bytes) = crate::Embedded::get("parser/gruvbox-dark.tmTheme")
&& let Ok(source) = std::str::from_utf8(bytes.data.as_ref())
{
textedit.add_theme_from_string(source);
textedit.set_code_theme("Gruvbox Dark");
}
textedit.set_continuous(true);
textedit.display_line_number(true);
textedit.use_global_statusbar(true);
textedit.set_font_size(14.0);
textedit.set_supports_undo(false);
script_canvas.set_widget(textedit);
center.set_center(script_canvas);
let mut preview_canvas = TheCanvas::new();
let mut preview_view = TheRenderView::new(TheId::named(BUILDER_SCRIPT_PREVIEW));
preview_view
.limiter_mut()
.set_max_size(Vec2::new(i32::MAX, i32::MAX));
preview_view.limiter_mut().set_max_width(560);
preview_view.limiter_mut().set_min_width(360);
preview_view.limiter_mut().set_min_height(360);
preview_canvas.set_widget(preview_view);
center.set_right(preview_canvas);
canvas.set_center(center);
canvas
}
fn activate(
&mut self,
ui: &mut TheUI,
ctx: &mut TheContext,
project: &Project,
server_ctx: &mut ServerContext,
) {
self.load_state_from_project(project, server_ctx);
self.refresh_ui(ui, ctx, project, server_ctx);
}
fn handle_event(
&mut self,
event: &TheEvent,
ui: &mut TheUI,
ctx: &mut TheContext,
project: &mut Project,
server_ctx: &mut ServerContext,
) -> bool {
match event {
TheEvent::ValueChanged(id, TheValue::Text(text))
if id.name == BUILDER_SCRIPT_EDITOR =>
{
self.source = text.clone();
self.render_preview(ui, ctx);
self.save_state_to_project(project, server_ctx, ctx);
true
}
TheEvent::StateChanged(id, TheWidgetState::Clicked)
if id.name == BUILDER_EDITOR_RESET_BUTTON =>
{
self.source = server_ctx
.curr_builder_graph_id
.and_then(|id| project.builder_graphs.get(&id))
.map(|asset| asset.graph_name.clone())
.map(|name| {
if name.starts_with("Wall Torch") {
BuilderGraph::preset_wall_torch_script_named(name)
} else if name.starts_with("Wall Lantern") {
BuilderGraph::preset_wall_lantern_script_named(name)
} else if name.starts_with("Campfire") {
BuilderGraph::preset_campfire_script_named(name)
} else if name.starts_with("Empty") {
BuilderGraph::empty_script_named(name)
} else {
BuilderGraph::preset_table_script_named(name)
}
})
.unwrap_or_else(Self::default_source);
self.refresh_ui(ui, ctx, project, server_ctx);
self.save_state_to_project(project, server_ctx, ctx);
true
}
TheEvent::ContextMenuSelected(id, item) if id.name == BUILDER_EDITOR_GRAPH_BUTTON => {
if item.name == "Builder Import Graph" {
ctx.ui.open_file_requester(
TheId::named("Builder Import Graph File"),
"Import Builder Script".into(),
TheFileExtension::new(
"Eldiron Builder Graph".into(),
vec!["buildergraph".to_string(), "json".to_string()],
),
);
true
} else if item.name == "Builder Export Graph" {
ctx.ui.save_file_requester(
TheId::named("Builder Export Graph File"),
"Export Builder Script".into(),
TheFileExtension::new(
"Eldiron Builder Graph".into(),
vec!["buildergraph".to_string()],
),
);
true
} else {
false
}
}
TheEvent::FileRequesterResult(id, paths)
if id.name == "Builder Import Graph File" && !paths.is_empty() =>
{
if let Some(path) = paths.first() {
match self.import_graph_file(path) {
Ok(()) => {
self.refresh_ui(ui, ctx, project, server_ctx);
self.save_state_to_project(project, server_ctx, ctx);
ctx.ui.send(TheEvent::SetStatusText(
id.clone(),
format!("Imported builder script from {}", path.to_string_lossy()),
));
}
Err(err) => {
ctx.ui.send(TheEvent::SetStatusText(
id.clone(),
format!("Builder import failed: {err}"),
));
}
}
}
true
}
TheEvent::FileRequesterResult(id, paths)
if id.name == "Builder Export Graph File" && !paths.is_empty() =>
{
if let Some(path) = paths.first() {
match self.export_graph_file(path) {
Ok(saved_path) => {
ctx.ui.send(TheEvent::SetStatusText(
id.clone(),
format!(
"Exported builder script to {}",
saved_path.to_string_lossy()
),
));
}
Err(err) => {
ctx.ui.send(TheEvent::SetStatusText(
id.clone(),
format!("Builder export failed: {err}"),
));
}
}
}
true
}
TheEvent::Custom(id, _) if id.name == "Builder Selection Changed" => {
self.load_state_from_project(project, server_ctx);
self.refresh_ui(ui, ctx, project, server_ctx);
true
}
_ => false,
}
}
}