use concinnity_core::components::{
Collider, PointLight, RectAreaLight, ReflectionProbe, SPOT_MAX_ANGLE_DEG, SpotLight, Transform,
TriggerVolume,
};
use concinnity_core::ecs::Entity;
use concinnity_core::ecs::World;
use concinnity_core::gfx::lines::Line;
use concinnity_cook::authoring::world::entry_handles;
use crate::editor::asset_handle::AssetHandle;
use crate::editor::hook::{EditorHook, entry_type};
use crate::editor::outlines;
use crate::editor::outlines::shapes::{self, Stroke};
use crate::editor::panels::form;
use crate::editor::viewport::billboards;
struct ShapeCtx<'a> {
world: &'a World,
entity: Entity,
ty: &'a str,
entry: &'a serde_json::Value,
vp: [f32; 2],
stroke: Stroke,
}
type ShapeFn = fn(&ShapeCtx, &mut Vec<Line>);
const EXTENTS: [(&str, outlines::Category, ShapeFn); 6] = [
("TriggerVolume", outlines::Category::Volumes, push_trigger),
("PointLight", outlines::Category::Lights, push_point_light),
("SpotLight", outlines::Category::Lights, push_spot_light),
("RectAreaLight", outlines::Category::Lights, push_rect_light),
("Camera3D", outlines::Category::Cameras, push_camera),
("ReflectionProbe", outlines::Category::Probes, push_probe),
];
impl EditorHook {
pub(in crate::editor::hook) fn push_extent_lines(
&self,
world: &World,
vp: [f32; 2],
out: &mut Vec<Line>,
) {
if !self.hud_visible || self.sim.playing() {
return;
}
if self.selection.is_empty() && !self.extent_show.any() {
return;
}
let mut budget = outlines::MAX_OUTLINED;
self.push_outline_pass(world, vp, out, true, &mut budget);
self.push_outline_pass(world, vp, out, false, &mut budget);
}
fn push_outline_pass(
&self,
world: &World,
vp: [f32; 2],
out: &mut Vec<Line>,
selected_pass: bool,
budget: &mut usize,
) {
let colliders_on = self.extent_show.contains(outlines::Category::Colliders);
let selected = self.selected_names();
let handles = entry_handles(&self.entries);
for (i, (e, name)) in self.entries.iter().zip(&handles).enumerate() {
if *budget == 0 {
return;
}
let (Some(name), Some(ty), Some(key)) =
(name.as_deref(), entry_type(e), self.entries.key_at(i))
else {
continue;
};
if self.handle_hidden(&AssetHandle::Entry(key))
|| selected.contains(name) != selected_pass
{
continue;
}
let extent = EXTENTS
.iter()
.find(|(t, ..)| *t == ty)
.filter(|(_, c, _)| selected_pass || self.extent_show.contains(*c));
if extent.is_none() && !colliders_on {
continue;
}
let Some(entity) = super::billboard::entity_by_name(world, name) else {
continue;
};
let before = out.len();
if let Some((_, _, shape)) = extent {
shape(
&ShapeCtx {
world,
entity,
ty,
entry: e,
vp,
stroke: outlines::stroke(ty, selected_pass),
},
out,
);
}
if colliders_on {
push_collider_outline(world, entity, out);
}
if out.len() > before {
*budget -= 1;
}
}
}
}
fn push_trigger(ctx: &ShapeCtx, out: &mut Vec<Line>) {
let Some(volume) = ctx.world.get::<TriggerVolume>(ctx.entity) else {
return;
};
let transform = anchored_transform(ctx.world, ctx.entity, volume.position, volume.rotation_deg);
let s = ctx.stroke;
let c = &volume.collider;
match c.shape.as_str() {
"ball" => shapes::push_sphere(out, transform.position, c.radius, s),
"capsule" => {
shapes::push_capsule(out, &transform.model_matrix(), c.radius, c.half_height, s);
}
_ => shapes::push_box(out, &transform.model_matrix(), c.half_extents, s),
}
}
fn push_point_light(ctx: &ShapeCtx, out: &mut Vec<Line>) {
let Some(light) = ctx.world.get::<PointLight>(ctx.entity) else {
return;
};
let center = anchored_position(ctx.world, ctx.entity, light.position);
shapes::push_sphere(out, center, light.range, ctx.stroke);
}
fn push_spot_light(ctx: &ShapeCtx, out: &mut Vec<Line>) {
let Some(light) = ctx.world.get::<SpotLight>(ctx.entity) else {
return;
};
let s = ctx.stroke;
let apex = anchored_position(ctx.world, ctx.entity, light.position);
let dir = light.unit_direction();
let outer = light
.outer_angle
.clamp(0.0, SPOT_MAX_ANGLE_DEG)
.to_radians();
shapes::push_cone(out, apex, dir, outer, light.range, s);
let inner = light.inner_angle.to_radians().clamp(0.0, outer);
let center = [
apex[0] + dir[0] * light.range * inner.cos(),
apex[1] + dir[1] * light.range * inner.cos(),
apex[2] + dir[2] * light.range * inner.cos(),
];
shapes::push_circle(
out,
center,
dir,
light.range * inner.sin(),
outlines::inner_stroke(s),
);
}
fn push_rect_light(ctx: &ShapeCtx, out: &mut Vec<Line>) {
let Some(light) = ctx.world.get::<RectAreaLight>(ctx.entity) else {
return;
};
let center = anchored_position(ctx.world, ctx.entity, light.center);
shapes::push_rect(
out,
center,
light.unit_normal(),
light.half_size,
light.range,
ctx.stroke,
);
}
fn push_probe(ctx: &ShapeCtx, out: &mut Vec<Line>) {
let Some(probe) = ctx.world.get::<ReflectionProbe>(ctx.entity) else {
return;
};
let position = anchored_position(ctx.world, ctx.entity, probe.position);
let model = Transform {
position,
rotation_deg: [0.0; 3],
scale: [1.0; 3],
}
.model_matrix();
shapes::push_box(out, &model, probe.half_extents, ctx.stroke);
if let Some(d) = probe.capture_distance {
shapes::push_box(out, &model, [d; 3], outlines::inner_stroke(ctx.stroke));
}
}
fn push_camera(ctx: &ShapeCtx, out: &mut Vec<Line>) {
let args = form::working_args(ctx.ty, ctx.entry.get("args").and_then(|v| v.as_object()));
let num = |key: &str, default: f32| {
args.get(key)
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(default)
};
let authored = billboards::position_of(&args).unwrap_or([0.0; 3]);
let origin = anchored_position(ctx.world, ctx.entity, authored);
let view =
concinnity_core::gfx::camera::view_matrix(origin, num("yaw", 0.0), num("pitch", 0.0));
let right = [view[0][0], view[1][0], view[2][0]];
let up = [view[0][1], view[1][1], view[2][1]];
let forward = [-view[0][2], -view[1][2], -view[2][2]];
let aspect = if ctx.vp[1] > 0.0 {
ctx.vp[0] / ctx.vp[1]
} else {
16.0 / 9.0
};
shapes::push_frustum(
out,
&shapes::Frustum {
origin,
right,
up,
forward,
fov_y_rad: num("fov_y_degrees", 75.0).to_radians(),
aspect,
near: num("near", 0.05),
far: num("view_distance", shapes::UNLIMITED_FRUSTUM_LENGTH),
},
ctx.stroke,
);
}
fn push_collider_outline(world: &World, entity: Entity, out: &mut Vec<Line>) {
let (Some(collider), Some(transform)) = (
world.get::<Collider>(entity),
world.get::<Transform>(entity),
) else {
return;
};
let s = outlines::stroke("Collider", false);
let c = &collider.0;
match c.shape.as_str() {
"ball" => {
let scale = transform.scale.iter().fold(0.0f32, |a, &b| a.max(b));
shapes::push_sphere(out, transform.position, c.radius * scale, s);
}
"capsule" => {
shapes::push_capsule(out, &transform.model_matrix(), c.radius, c.half_height, s)
}
_ => shapes::push_box(out, &transform.model_matrix(), c.half_extents, s),
}
}
fn anchored_position(world: &World, entity: Entity, authored: [f32; 3]) -> [f32; 3] {
world
.get::<Transform>(entity)
.map(|t| t.position)
.unwrap_or(authored)
}
fn anchored_transform(
world: &World,
entity: Entity,
position: [f32; 3],
rotation_deg: [f32; 3],
) -> Transform {
world
.get::<Transform>(entity)
.cloned()
.unwrap_or(Transform {
position,
rotation_deg,
scale: [1.0; 3],
})
}
#[cfg(test)]
mod tests {
use super::*;
use concinnity_core::components::Camera3D;
use concinnity_core::components::PropCollider;
use concinnity_core::ecs::ComponentSlot;
use concinnity_host::thread::asset_id;
const VP: [f32; 2] = [1280.0, 720.0];
fn hook(entries: Vec<serde_json::Value>) -> EditorHook {
EditorHook::new("unused.jsonl".to_string(), entries)
}
fn entry(name: &str, ty: &str) -> serde_json::Value {
serde_json::json!({"type": ty, "args": {"$id": name}})
}
fn world_with<C: ComponentSlot>(name: &str, component: C) -> World {
asset_id::reset_interner();
let id = asset_id::intern(name);
let mut world = World::new();
let entity = world.push(component);
world.identify(entity, id);
world
}
fn lines_of(h: &EditorHook, world: &World) -> Vec<Line> {
let mut out = Vec::new();
h.push_extent_lines(world, VP, &mut out);
out
}
#[test]
fn a_selected_point_light_outlines_its_range_sphere() {
let world = world_with(
"lamp",
PointLight {
position: [1.0, 2.0, 3.0],
range: 6.0,
..Default::default()
},
);
let mut h = hook(vec![entry("lamp", "PointLight")]);
assert!(
lines_of(&h, &world).is_empty(),
"nothing selected, nothing drawn"
);
h.select_named("lamp");
let lines = lines_of(&h, &world);
assert_eq!(lines.len(), 3 * shapes::CIRCLE_SEGMENTS);
for l in &lines {
let d = [l.start[0] - 1.0, l.start[1] - 2.0, l.start[2] - 3.0];
let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
assert!((r - 6.0).abs() < 1e-3, "{r}");
}
}
#[test]
fn a_category_toggle_outlines_without_selection_and_dimmer() {
let world = world_with(
"lamp",
PointLight {
range: 6.0,
..Default::default()
},
);
let mut h = hook(vec![entry("lamp", "PointLight")]);
h.extent_show = h.extent_show.toggled(outlines::Category::Lights);
let layer = lines_of(&h, &world);
assert_eq!(layer.len(), 3 * shapes::CIRCLE_SEGMENTS);
h.select_named("lamp");
let selected = lines_of(&h, &world);
assert_eq!(selected.len(), layer.len());
assert!(selected[0].start_color[3] > layer[0].start_color[3]);
assert!(selected[0].width_px > layer[0].width_px);
}
#[test]
fn a_spot_light_draws_its_cone_and_inner_circle() {
let world = world_with(
"spot",
SpotLight {
position: [0.0, 5.0, 0.0],
direction: [0.0, -1.0, 0.0],
range: 10.0,
inner_angle: 18.0,
outer_angle: 30.0,
..Default::default()
},
);
let mut h = hook(vec![entry("spot", "SpotLight")]);
h.select_named("spot");
let lines = lines_of(&h, &world);
assert_eq!(
lines.len(),
shapes::CONE_SIDES + 2 * shapes::CIRCLE_SEGMENTS,
"outer cone + inner circle"
);
let faintest = lines
.iter()
.map(|l| l.start_color[3])
.fold(f32::MAX, f32::min);
assert!(faintest < lines[0].start_color[3]);
}
#[test]
fn trigger_volume_shapes_follow_the_live_transform() {
let mut world = world_with(
"zone",
TriggerVolume {
position: [0.0; 3],
collider: PropCollider {
shape: concinnity_core::components::PropColliderShape::Ball,
radius: 2.0,
..Default::default()
},
..Default::default()
},
);
let mut h = hook(vec![entry("zone", "TriggerVolume")]);
h.select_named("zone");
let e = world
.resource::<concinnity_core::ecs::EntityById>()
.unwrap()
.iter()
.next()
.map(|(_, e)| e)
.unwrap();
world.insert(
e,
Transform {
position: [10.0, 0.0, 0.0],
rotation_deg: [0.0; 3],
scale: [1.0; 3],
},
);
let lines = lines_of(&h, &world);
assert_eq!(lines.len(), 3 * shapes::CIRCLE_SEGMENTS);
for l in &lines {
let d = [l.start[0] - 10.0, l.start[1], l.start[2]];
let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
assert!((r - 2.0).abs() < 1e-3, "outline follows the Transform");
}
}
#[test]
fn colliders_show_only_through_their_toggle() {
let mut world = world_with(
"crate",
Collider(PropCollider {
shape: concinnity_core::components::PropColliderShape::Cuboid,
half_extents: [1.0, 1.0, 1.0],
..Default::default()
}),
);
let e = world
.resource::<concinnity_core::ecs::EntityById>()
.unwrap()
.iter()
.next()
.map(|(_, e)| e)
.unwrap();
world.insert(e, Transform::default());
let mut h = hook(vec![entry("crate", "Prop")]);
h.select_named("crate");
assert!(lines_of(&h, &world).is_empty());
h.extent_show = h.extent_show.toggled(outlines::Category::Colliders);
assert_eq!(lines_of(&h, &world).len(), shapes::BOX_EDGES);
}
#[test]
fn hidden_entries_and_a_hidden_hud_draw_nothing() {
let world = world_with(
"lamp",
PointLight {
range: 6.0,
..Default::default()
},
);
let mut h = hook(vec![entry("lamp", "PointLight")]);
h.select_named("lamp");
assert!(!lines_of(&h, &world).is_empty());
h.hidden_assets.insert(h.handle_for("lamp"));
assert!(
lines_of(&h, &world).is_empty(),
"editor-hidden entries skip"
);
h.hidden_assets.clear();
h.hud_visible = false;
assert!(
lines_of(&h, &world).is_empty(),
"F1 clears the outlines too"
);
}
#[test]
fn the_extent_table_is_one_row_per_type_outside_colliders() {
let mut seen = std::collections::HashSet::new();
for (ty, c, _) in EXTENTS {
assert!(seen.insert(ty), "{ty} repeats");
assert!(
concinnity_cook::authoring::registry::RegisteredType::parse(ty).is_some(),
"{ty} is not a registered component type"
);
assert_ne!(
c,
outlines::Category::Colliders,
"{ty}: colliders are not type-keyed"
);
}
for (c, label) in outlines::Category::LABELED {
if c == outlines::Category::Colliders {
continue;
}
assert!(
EXTENTS.iter().any(|(_, row, _)| *row == c),
"{label} has no outlineable type"
);
}
}
#[test]
fn a_probe_with_a_capture_distance_outlines_its_capture_cube() {
let world = world_with(
"probe",
ReflectionProbe {
position: [1.0, 2.0, 3.0],
half_extents: [2.0; 3],
capture_distance: Some(6.0),
},
);
let mut h = hook(vec![entry("probe", "ReflectionProbe")]);
h.select_named("probe");
let lines = lines_of(&h, &world);
assert_eq!(
lines.len(),
2 * shapes::BOX_EDGES,
"influence box + capture cube"
);
let (influence, capture) = lines.split_at(shapes::BOX_EDGES);
assert!(capture[0].start_color[3] < influence[0].start_color[3]);
let reach = capture
.iter()
.flat_map(|l| [l.start, l.end])
.fold([0.0f32; 3], |acc, p| {
core::array::from_fn(|i| acc[i].max((p[i] - [1.0, 2.0, 3.0][i]).abs()))
});
assert_eq!(reach, [6.0; 3]);
}
#[test]
fn rect_probe_and_camera_extents_emit_lines_when_selected() {
let world = world_with("panel", RectAreaLight::default());
let mut h = hook(vec![entry("panel", "RectAreaLight")]);
h.select_named("panel");
assert_eq!(lines_of(&h, &world).len(), 5);
let world = world_with("probe", ReflectionProbe::default());
let mut h = hook(vec![entry("probe", "ReflectionProbe")]);
h.select_named("probe");
assert_eq!(lines_of(&h, &world).len(), shapes::BOX_EDGES);
let world = world_with(
"shot",
Camera3D {
position: [0.0; 3],
view_matrix: concinnity_core::gfx::camera::view_matrix([0.0; 3], 0.0, 0.0),
fov_y_degrees: 75.0,
near: 0.05,
view_distance: None,
yaw: 0.0,
pitch: 0.0,
desired_move: [0.0; 3],
jump_requested: false,
interact_requested: false,
controller: None,
},
);
let mut h = hook(vec![entry("shot", "Camera3D")]);
h.select_named("shot");
assert_eq!(lines_of(&h, &world).len(), shapes::BOX_EDGES);
let mut h = hook(vec![serde_json::json!({
"type": "Camera3D",
"args": {"$id": "shot", "near": "0.05", "view_distance": null}
})]);
h.select_named("shot");
assert_eq!(lines_of(&h, &world).len(), shapes::BOX_EDGES);
}
}