kitgit

tirbofish/dropbear · commit

c2a063c300e280200f96a7075af98a77334d774b

feature: improved copy and pasting. feature: improved onrails perf: changed backend of hitbox wireframe to use DebugDraw instead. bug: too much GPU usage and redback-runtime is not putting the entities correctly (likely because of the physics).

Unverified

Thribhu K <4tkbytes@pm.me> · 2026-03-28 15:16

view full diff

diff --git a/crates/dropbear-engine/src/debug.rs b/crates/dropbear-engine/src/debug.rs
index 3a3b114..f3f3323 100644
--- a/crates/dropbear-engine/src/debug.rs
+++ b/crates/dropbear-engine/src/debug.rs
@@ -5,10 +5,10 @@ use glam::{Mat4, Quat, Vec3, Vec4};
 use std::sync::Arc;
 use wgpu::{
     BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry,
-    BindingResource, BindingType, BufferBindingType, BufferUsages, CompareFunction,
-    DepthStencilState, LoadOp, MultisampleState, Operations, PrimitiveState, PrimitiveTopology,
-    RenderPassColorAttachment, RenderPassDepthStencilAttachment, RenderPassDescriptor,
-    RenderPipeline, RenderPipelineDescriptor, ShaderStages, StoreOp, TextureFormat,
+    BindingResource, BindingType, BufferBindingType, BufferUsages,
+    LoadOp, MultisampleState, Operations, PrimitiveState, PrimitiveTopology,
+    RenderPassColorAttachment, RenderPassDescriptor,
+    RenderPipeline, RenderPipelineDescriptor, ShaderStages, StoreOp,
     VertexBufferLayout, VertexState,
 };
 
@@ -510,13 +510,7 @@ impl DebugDrawPipeline {
                     polygon_mode: Default::default(),
                     conservative: false,
                 },
-                depth_stencil: Some(DepthStencilState {
-                    format: TextureFormat::Depth32Float,
-                    depth_write_enabled: Some(false), // don't write to depth, just read
-                    depth_compare: Some(CompareFunction::Less),
-                    stencil: Default::default(),
-                    bias: Default::default(),
-                }),
+                depth_stencil: None, // do not use depth_stentil
                 multisample: MultisampleState {
                     count: sample_count,
                     mask: !0,
@@ -561,14 +555,7 @@ impl DebugDrawPipeline {
                     store: StoreOp::Store,
                 },
             })],
-            depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
-                view: &graphics.depth_texture.view,
-                depth_ops: Some(Operations {
-                    load: LoadOp::Load, // read existing depth
-                    store: StoreOp::Store,
-                }),
-                stencil_ops: None,
-            }),
+            depth_stencil_attachment: None,
             timestamp_writes: None,
             occlusion_query_set: None,
             multiview_mask: None,
diff --git a/crates/dropbear-engine/src/pipelines/light_cube.rs b/crates/dropbear-engine/src/pipelines/light_cube.rs
index 8080be6..bfda81b 100644
--- a/crates/dropbear-engine/src/pipelines/light_cube.rs
+++ b/crates/dropbear-engine/src/pipelines/light_cube.rs
@@ -1,5 +1,4 @@
 use crate::buffer::StorageBuffer;
-use crate::entity::{EntityTransform, Transform};
 use crate::graphics::SharedGraphicsContext;
 use crate::lighting::{Light, LightArrayUniform, MAX_LIGHTS};
 use crate::model::{ModelVertex, Vertex};
@@ -131,20 +130,11 @@ impl LightCubePipeline {
 
         let mut light_index: usize = 0;
 
-        for (s_trans, e_trans, light) in world
-            .query::<(Option<&Transform>, Option<&EntityTransform>, &mut Light)>()
-            .iter()
-        {
+        for light in world.query::<&mut Light>().iter() {
             light.update(graphics.as_ref());
 
-            let instance: InstanceInput = if let Some(transform) = e_trans {
-                let sync_transform = transform.sync();
-                sync_transform.matrix().into()
-            } else if let Some(transform) = s_trans {
-                transform.matrix().into()
-            } else {
-                panic!("No Transform or EntityTransform available for this light cube");
-            };
+
+            let instance: InstanceInput = light.component.to_transform().matrix().into();
 
             light
                 .instance_buffer
diff --git a/crates/eucalyptus-core/src/lighting.rs b/crates/eucalyptus-core/src/lighting.rs
index 5d6b196..d6c7deb 100644
--- a/crates/eucalyptus-core/src/lighting.rs
+++ b/crates/eucalyptus-core/src/lighting.rs
@@ -10,6 +10,7 @@ use crate::states::SerializedLight;
 use crate::types::{NColour, NVector3};
 use dropbear_engine::attenuation::ATTENUATION_PRESETS;
 use dropbear_engine::entity::{EntityTransform, Transform, inspect_rotation_dquat};
+use crate::hierarchy::EntityTransformExt;
 use dropbear_engine::graphics::SharedGraphicsContext;
 use dropbear_engine::lighting::{Light, LightType};
 use egui::{CollapsingHeader, ComboBox, DragValue, Ui};
@@ -67,7 +68,7 @@ impl Component for Light {
     ) {
         let synced = &mut self.component;
         if let Ok(entity_transform) = world.query_one::<&EntityTransform>(entity).get() {
-            let transform = entity_transform.sync();
+            let transform = entity_transform.propagate(world, entity);
             synced.position = transform.position;
             synced.direction = (transform.rotation * LIGHT_FORWARD_AXIS).normalize_or_zero();
         } else if let Ok(transform) = world.query_one::<&Transform>(entity).get() {
diff --git a/crates/eucalyptus-core/src/scripting/jni.rs b/crates/eucalyptus-core/src/scripting/jni.rs
index 15d6ab7..884785c 100644
--- a/crates/eucalyptus-core/src/scripting/jni.rs
+++ b/crates/eucalyptus-core/src/scripting/jni.rs
@@ -266,6 +266,18 @@ impl JavaContext {
                 let lib_path_arg = format!("-Djava.library.path={}", combined_path);
                 args_log.push(lib_path_arg.clone());
                 jvm_args = jvm_args.option(lib_path_arg);
+
+                let lib_name = if cfg!(target_os = "windows") {
+                    "eucalyptus_core.dll"
+                } else if cfg!(target_os = "macos") {
+                    "libeucalyptus_core.dylib"
+                } else {
+                    "libeucalyptus_core.so"
+                };
+                let core_lib_path = path.join(lib_name);
+                let core_lib_arg = format!("-Deucalyptus.core.lib={}", core_lib_path.display());
+                args_log.push(core_lib_arg.clone());
+                jvm_args = jvm_args.option(core_lib_arg);
             }
         };
 
diff --git a/crates/eucalyptus-core/src/transform.rs b/crates/eucalyptus-core/src/transform.rs
index 567eecf..d69ecdc 100644
--- a/crates/eucalyptus-core/src/transform.rs
+++ b/crates/eucalyptus-core/src/transform.rs
@@ -1,3 +1,4 @@
+use crate::camera::{CameraComponent, CameraType};
 use crate::component::{
     Component, ComponentDescriptor, ComponentInitFuture, DisabilityFlags, InspectableComponent,
     SerializedComponent,
@@ -8,11 +9,11 @@ use crate::ptr::WorldPtr;
 use crate::scripting::jni::utils::{FromJObject, ToJObject};
 use crate::scripting::native::DropbearNativeError;
 use crate::scripting::result::DropbearNativeResult;
-use crate::types::{NTransform, NVector3};
+use crate::types::{NQuaternion, NTransform, NVector3};
 use ::jni::objects::{JObject, JValue};
 use ::jni::{Env, jni_sig, jni_str};
 use dropbear_engine::camera::Camera;
-use dropbear_engine::entity::{EntityTransform, Transform};
+use dropbear_engine::entity::{EntityTransform, Transform, inspect_rotation_dquat};
 use dropbear_engine::graphics::SharedGraphicsContext;
 use egui::{CollapsingHeader, ComboBox, Ui};
 use glam::{DMat3, DQuat, DVec3, Vec3};
@@ -21,23 +22,45 @@ use splines::{Interpolation, Key, Spline};
 use std::fmt::{Display, Formatter};
 use std::sync::Arc;
 
+/// A single point on an [`OnRails`] path, capturing both position and an optional explicit
+/// rotation. When `rotation` is `None`, the sampler derives orientation from the path tangent.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct RailPoint {
+    pub position: DVec3,
+    /// Explicit look-at rotation captured from the camera. `None` means tangent-derived.
+    pub rotation: Option<DQuat>,
+}
+
+impl Default for RailPoint {
+    fn default() -> Self {
+        Self {
+            position: DVec3::ZERO,
+            rotation: None,
+        }
+    }
+}
+
 /// Allows for the entity to have constricted movement, such as a Camera that follows a player
 /// on a set path.
 #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)]
 pub struct OnRails {
     /// Whether the component actively moves the entity along the path each frame.
     pub enabled: bool,
-    pub path: Vec<DVec3>,
+    pub path: Vec<RailPoint>,
     /// Progress of the entity on the spline between 0.0 to 1.0.
     pub progress: f32, // between 0.0 to 1.0
     pub drive: RailDrive,
     /// Allows for showing the debug path of the rail.
     #[serde(skip)]
     pub debug: bool,
+    /// Stores the desired position and rotation to be applied to [`EntityTransform`] after the
+    /// component update loop, avoiding a double-borrow of the same archetype in hecs.
+    #[serde(skip)]
+    pub pending_transform: Option<(DVec3, DQuat)>,
 }
 
 /// Returns the progress `t` (0.0–1.0) of the path point closest to `pos`.
-fn project_to_path(path: &[DVec3], pos: DVec3) -> f32 {
+fn project_to_path(path: &[RailPoint], pos: DVec3) -> f32 {
     let n = path.len();
     if n < 2 {
         return 0.0;
@@ -45,8 +68,8 @@ fn project_to_path(path: &[DVec3], pos: DVec3) -> f32 {
     let mut best_t = 0.0f32;
     let mut best_dist_sq = f64::MAX;
     for i in 0..(n - 1) {
-        let a = path[i];
-        let b = path[i + 1];
+        let a = path[i].position;
+        let b = path[i + 1].position;
         let t_a = i as f32 / (n - 1) as f32;
         let t_b = (i + 1) as f32 / (n - 1) as f32;
         let ab = b - a;
@@ -66,14 +89,14 @@ fn project_to_path(path: &[DVec3], pos: DVec3) -> f32 {
     best_t
 }
 
-fn path_tangent_rotation(path: &[DVec3], t: f32) -> DQuat {
+fn path_tangent_rotation(path: &[RailPoint], t: f32) -> DQuat {
     let n = path.len();
     if n < 2 {
         return DQuat::IDENTITY;
     }
     let t_clamped = (t as f64 * (n - 1) as f64).clamp(0.0, (n - 2) as f64);
     let i = t_clamped as usize;
-    let forward = (path[i + 1] - path[i]).normalize_or_zero();
+    let forward = (path[i + 1].position - path[i].position).normalize_or_zero();
     if forward.length_squared() < 1e-10 {
         return DQuat::IDENTITY;
     }
@@ -87,19 +110,66 @@ fn path_tangent_rotation(path: &[DVec3], t: f32) -> DQuat {
     DQuat::from_mat3(&DMat3::from_cols(right, up, -forward))
 }
 
-fn sample_path_linear(path: &[DVec3], t: f32) -> DVec3 {
+
+fn sample_path_rotation(path: &[RailPoint], t: f32) -> DQuat {
+    let n = path.len();
+    if n < 2 {
+        return DQuat::IDENTITY;
+    }
+    let t_clamped = t.clamp(0.0, 1.0);
+    let seg_f = t_clamped as f64 * (n - 1) as f64;
+    let i = (seg_f as usize).min(n - 2);
+    let frac = (seg_f - i as f64) as f32;
+
+    match (path[i].rotation, path[i + 1].rotation) {
+        (Some(a), Some(b)) => a.slerp(b, frac as f64),
+        (Some(a), None) => a,
+        (None, Some(b)) => b,
+        (None, None) => path_tangent_rotation(path, t),
+    }
+}
+
+/// Build a look-rotation quaternion (same -Z-forward convention as [`path_tangent_rotation`])
+/// from an arbitrary forward direction vector.
+fn look_rot_from_forward(forward: DVec3) -> DQuat {
+    let forward = forward.normalize_or_zero();
+    if forward.length_squared() < 1e-10 {
+        return DQuat::IDENTITY;
+    }
+    let world_up = if forward.dot(DVec3::Y).abs() > 0.99 {
+        DVec3::Z
+    } else {
+        DVec3::Y
+    };
+    let right = world_up.cross(forward).normalize();
+    let up = forward.cross(right).normalize();
+    DQuat::from_mat3(&DMat3::from_cols(right, up, -forward))
+}
+
+/// Apply a sampled OnRails rotation to a [`Camera`], keeping `yaw`/`pitch` in sync so
+/// user mouse-drag doesn't snap after the rails set the orientation.
+fn apply_rot_to_camera(camera: &mut Camera, new_eye: DVec3, rot: DQuat) {
+    let forward = rot * DVec3::NEG_Z;
+    camera.eye = new_eye;
+    camera.target = new_eye + forward;
+    let dir = forward.normalize();
+    camera.pitch = dir.y.clamp(-1.0, 1.0).asin();
+    camera.yaw = dir.z.atan2(dir.x);
+}
+
+fn sample_path_linear(path: &[RailPoint], t: f32) -> DVec3 {
     let n = path.len();
     if n == 0 {
         return DVec3::ZERO;
     }
     if n == 1 {
-        return path[0];
+        return path[0].position;
     }
     let t = t.clamp(0.0, 1.0) as f64;
     let seg_f = t * (n - 1) as f64;
     let i = (seg_f as usize).min(n - 2);
     let frac = seg_f - i as f64;
-    path[i].lerp(path[i + 1], frac)
+    path[i].position.lerp(path[i + 1].position, frac)
 }
 
 /// How the progression of the [`OnRails`] component is handled.
@@ -186,6 +256,16 @@ impl Component for OnRails {
         dt: f32,
         _graphics: Arc<SharedGraphicsContext>,
     ) {
+        // this might be bad practice, idk
+        for (rails, et) in
+            world.query::<(&mut OnRails, &mut EntityTransform)>().iter()
+        {
+            if let Some((pos, rot)) = rails.pending_transform.take() {
+                et.world_mut().position = pos;
+                et.world_mut().rotation = rot;
+            }
+        }
+
         let n = self.path.len();
         if n < 2 {
             return;
@@ -197,7 +277,7 @@ impl Component for OnRails {
                     .iter()
                     .enumerate()
                     .map(|(i, p)| {
-                        Key::new(i as f64 / (n - 1) as f64, get(p), Interpolation::Linear)
+                        Key::new(i as f64 / (n - 1) as f64, get(&p.position), Interpolation::Linear)
                     })
                     .collect(),
             )
@@ -227,8 +307,8 @@ impl Component for OnRails {
                 let mut best_t = self.progress;
                 let mut best_dist_sq = f64::MAX;
                 for i in 0..(n - 1) {
-                    let a = self.path[i];
-                    let b = self.path[i + 1];
+                    let a = self.path[i].position;
+                    let b = self.path[i + 1].position;
                     let t_a = i as f32 / (n - 1) as f32;
                     let t_b = (i + 1) as f32 / (n - 1) as f32;
                     let ab = b - a;
@@ -276,15 +356,11 @@ impl Component for OnRails {
             sz.clamped_sample(t),
         ) {
             let new_pos = DVec3::new(x, y, z);
-            let rot = path_tangent_rotation(&self.path, self.progress);
-            if let Ok(mut et) = world.get::<&mut EntityTransform>(entity) {
-                et.world_mut().position = new_pos;
-                et.world_mut().rotation = rot;
-            }
+            let rot = sample_path_rotation(&self.path, self.progress);
+
+            self.pending_transform = Some((new_pos, rot));
             if let Ok(mut camera) = world.get::<&mut Camera>(entity) {
-                camera.eye = new_pos;
-                let forward = rot * DVec3::NEG_Z;
-                camera.target = new_pos + forward;
+                apply_rot_to_camera(&mut camera, new_pos, rot);
             }
         }
     }
@@ -314,15 +390,13 @@ impl InspectableComponent for OnRails {
                 if pos.distance_squared(expected) > 1e-8 {
                     self.progress = project_to_path(&self.path, pos);
                     let snapped = sample_path_linear(&self.path, self.progress);
-                    let rot = path_tangent_rotation(&self.path, self.progress);
+                    let rot = sample_path_rotation(&self.path, self.progress);
                     if let Ok(mut et) = world.get::<&mut EntityTransform>(entity) {
                         et.world_mut().position = snapped;
                         et.world_mut().rotation = rot;
                     }
                     if let Ok(mut camera) = world.get::<&mut Camera>(entity) {
-                        camera.eye = snapped;
-                        let forward = rot * DVec3::NEG_Z;
-                        camera.target = snapped + forward;
+                        apply_rot_to_camera(&mut camera, snapped, rot);
                     }
                 }
             }
@@ -343,15 +417,13 @@ impl InspectableComponent for OnRails {
                 );
                 if slider.changed() && n >= 2 {
                     let pos = sample_path_linear(&self.path, self.progress);
-                    let rot = path_tangent_rotation(&self.path, self.progress);
+                    let rot = sample_path_rotation(&self.path, self.progress);
                     if let Ok(mut et) = world.get::<&mut EntityTransform>(entity) {
                         et.world_mut().position = pos;
                         et.world_mut().rotation = rot;
                     }
                     if let Ok(mut camera) = world.get::<&mut Camera>(entity) {
-                        camera.eye = pos;
-                        let forward = rot * DVec3::NEG_Z;
-                        camera.target = pos + forward;
+                        apply_rot_to_camera(&mut camera, pos, rot);
                     }
                 }
 
@@ -438,15 +510,39 @@ impl InspectableComponent for OnRails {
                     .default_open(false)
                     .id_salt(format!("OnRails Path {}", entity.to_bits()))
                     .show(ui, |ui| {
+                        ui.style_mut().interaction.selectable_labels = false;
                         let mut remove_idx: Option<usize> = None;
                         for (i, point) in self.path.iter_mut().enumerate() {
-                            ui.horizontal(|ui| {
-                                ui.label(format!("[{}]", i));
-                                ui.add(egui::DragValue::new(&mut point.x).speed(0.1).prefix("X:"));
-                                ui.add(egui::DragValue::new(&mut point.y).speed(0.1).prefix("Y:"));
-                                ui.add(egui::DragValue::new(&mut point.z).speed(0.1).prefix("Z:"));
-                                if ui.small_button("-").clicked() {
-                                    remove_idx = Some(i);
+                            ui.push_id(i, |ui| {
+                                ui.horizontal(|ui| {
+                                    ui.label(format!("[{}]", i));
+                                    ui.add(egui::DragValue::new(&mut point.position.x).speed(0.1).prefix("X:"));
+                                    ui.add(egui::DragValue::new(&mut point.position.y).speed(0.1).prefix("Y:"));
+                                    ui.add(egui::DragValue::new(&mut point.position.z).speed(0.1).prefix("Z:"));
+                                    let has_rot = point.rotation.is_some();
+                                    if ui
+                                        .small_button(if has_rot { "R\u{2713}" } else { "R" })
+                                        .on_hover_text(if has_rot {
+                                            "Explicit rotation set \u{2014} click to clear (use tangent)"
+                                        } else {
+                                            "No explicit rotation (tangent-derived) \u{2014} click to pin"
+                                        })
+                                        .clicked()
+                                    {
+                                        point.rotation = if has_rot { None } else { Some(DQuat::IDENTITY) };
+                                    }
+                                    if ui.small_button("-").clicked() {
+                                        remove_idx = Some(i);
+                                    }
+                                });
+                                if let Some(ref mut rot) = point.rotation {
+                                    ui.indent(format!("rail_rot_{}", i), |ui| {
+                                        inspect_rotation_dquat(
+                                            ui,
+                                            ("rail_point_rot", entity.to_bits(), i),
+                                            rot,
+                                        );
+                                    });
                                 }
                             });
                         }
@@ -454,8 +550,33 @@ impl InspectableComponent for OnRails {
                             self.path.remove(idx);
                         }
                         if ui.button("+ Add Point").clicked() {
-                            let last = self.path.last().copied().unwrap_or(DVec3::ZERO);
-                            self.path.push(last + DVec3::new(1.0, 0.0, 0.0));
+                            let last = self.path.last().map(|p| p.position).unwrap_or(DVec3::ZERO);
+                            self.path.push(RailPoint {
+                                position: last + DVec3::new(1.0, 0.0, 0.0),
+                                rotation: None,
+                            });
+                        }
+                        if ui.button("+ Add Point from Camera").clicked() {
+                            let cam_data = world
+                                .query::<(&Camera, &CameraComponent)>()
+                                .iter()
+                                .find_map(|(cam, comp)| {
+                                    matches!(comp.camera_type, CameraType::Debug)
+                                        .then_some((cam.eye, (cam.target - cam.eye).normalize_or_zero()))
+                                })
+                                .or_else(|| {
+                                    world
+                                        .query::<&Camera>()
+                                        .iter()
+                                        .next()
+                                        .map(|cam| (cam.eye, (cam.target - cam.eye).normalize_or_zero()))
+                                });
+                            if let Some((pos, fwd)) = cam_data {
+                                self.path.push(RailPoint {
+                                    position: pos,
+                                    rotation: Some(look_rot_from_forward(fwd)),
+                                });
+                            }
                         }
                     });
 
@@ -465,10 +586,10 @@ impl InspectableComponent for OnRails {
                 if self.debug && self.path.len() >= 2 {
                     if let Some(dd) = graphics.debug_draw.lock().as_mut() {
                         let red = [1.0, 0.0, 0.0, 1.0];
-                        dd.draw_point(self.path[0].as_vec3(), 0.1, red);
+                        dd.draw_point(self.path[0].position.as_vec3(), 0.1, red);
                         for i in 0..(self.path.len() - 1) {
-                            let a = self.path[i].as_vec3();
-                            let b = self.path[i + 1].as_vec3();
+                            let a = self.path[i].position.as_vec3();
+                            let b = self.path[i + 1].position.as_vec3();
                             dd.draw_line(a, b, red);
                             dd.draw_point(b, 0.1, red);
                         }
@@ -936,7 +1057,7 @@ fn on_rails_get_path_point(
         .path
         .get(index as usize)
         .ok_or(DropbearNativeError::InvalidArgument)?;
-    Ok(NVector3::from(point))
+    Ok(NVector3::from(&point.position))
 }
 
 #[dropbear_macro::export(
@@ -972,7 +1093,74 @@ fn on_rails_push_path_point(
     let mut rails = world
         .get::<&mut OnRails>(entity)
         .map_err(|_| DropbearNativeError::MissingComponent)?;
-    rails.path.push(DVec3::from(point));
+    rails.path.push(RailPoint { position: DVec3::from(point), rotation: None });
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(
+        class = "com.dropbear.components.camera.OnRailsNative",
+        func = "getPathPointHasRotation"
+    ),
+    c
+)]
+fn on_rails_get_path_point_has_rotation(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    index: i32,
+) -> DropbearNativeResult<bool> {
+    let rails = world
+        .get::<&OnRails>(entity)
+        .map_err(|_| DropbearNativeError::MissingComponent)?;
+    let point = rails
+        .path
+        .get(index as usize)
+        .ok_or(DropbearNativeError::InvalidArgument)?;
+    Ok(point.rotation.is_some())
+}
+
+#[dropbear_macro::export(
+    kotlin(
+        class = "com.dropbear.components.camera.OnRailsNative",
+        func = "getPathPointRotation"
+    ),
+    c
+)]
+fn on_rails_get_path_point_rotation(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    index: i32,
+) -> DropbearNativeResult<NQuaternion> {
+    let rails = world
+        .get::<&OnRails>(entity)
+        .map_err(|_| DropbearNativeError::MissingComponent)?;
+    let point = rails
+        .path
+        .get(index as usize)
+        .ok_or(DropbearNativeError::InvalidArgument)?;
+    Ok(NQuaternion::from(point.rotation.unwrap_or(DQuat::IDENTITY)))
+}
+
+#[dropbear_macro::export(
+    kotlin(
+        class = "com.dropbear.components.camera.OnRailsNative",
+        func = "pushPathPointWithRotation"
+    ),
+    c
+)]
+fn on_rails_push_path_point_with_rotation(
+    #[dropbear_macro::define(WorldPtr)] world: &hecs::World,
+    #[dropbear_macro::entity] entity: hecs::Entity,
+    point: &NVector3,
+    rotation: &NQuaternion,
+) -> DropbearNativeResult<()> {
+    let mut rails = world
+        .get::<&mut OnRails>(entity)
+        .map_err(|_| DropbearNativeError::MissingComponent)?;
+    rails.path.push(RailPoint {
+        position: DVec3::from(point),
+        rotation: Some(DQuat::from(*rotation)),
+    });
     Ok(())
 }
 
diff --git a/crates/eucalyptus-editor/src/editor/dock.rs b/crates/eucalyptus-editor/src/editor/dock.rs
index 54c842b..bc8bf67 100644
--- a/crates/eucalyptus-editor/src/editor/dock.rs
+++ b/crates/eucalyptus-editor/src/editor/dock.rs
@@ -37,6 +37,7 @@ pub struct EditorTabViewer<'a> {
     pub gizmo: &'a mut Gizmo,
     pub world: &'a mut World,
     pub selected_entity: &'a mut Option<Entity>,
+    pub selected_entities: &'a mut Vec<Entity>,
     pub viewport_mode: &'a mut ViewportMode,
     pub undo_stack: &'a mut Vec<UndoableAction>,
     pub signal: &'a mut VecDeque<Signal>,
diff --git a/crates/eucalyptus-editor/src/editor/docks/asset_viewer.rs b/crates/eucalyptus-editor/src/editor/docks/asset_viewer.rs
index 5b57ef0..c096ee4 100644
--- a/crates/eucalyptus-editor/src/editor/docks/asset_viewer.rs
+++ b/crates/eucalyptus-editor/src/editor/docks/asset_viewer.rs
@@ -1366,6 +1366,7 @@ impl<'a> EditorTabViewer<'a> {
     }
 
     pub(crate) fn handle_tree_selection(&mut self, cfg: &mut StaticallyKept, items: &[u64]) {
+        self.selected_entities.clear();
         for node_id in items {
             self.resolve_tree_node(cfg, *node_id);
         }
@@ -1445,6 +1446,7 @@ impl<'a> EditorTabViewer<'a> {
         } else if let Some(entity) = Self::entity_from_node_id(node_id) {
             cfg.root_node_selected = false;
             *self.selected_entity = Some(entity);
+            self.selected_entities.push(entity);
         }
     }
 
diff --git a/crates/eucalyptus-editor/src/editor/docks/entity_list.rs b/crates/eucalyptus-editor/src/editor/docks/entity_list.rs
index 18c0311..4ece66f 100644
--- a/crates/eucalyptus-editor/src/editor/docks/entity_list.rs
+++ b/crates/eucalyptus-editor/src/editor/docks/entity_list.rs
@@ -32,21 +32,29 @@ impl<'a> EditorTabViewer<'a> {
                         .clone()
                         .unwrap_or("Scene".to_string())
                 };
-                builder.node(
-                    // root node/scene display
-                    NodeBuilder::dir(u64::MAX)
-                        .label(format!("Scene: {}", current_scene_name))
-                        .context_menu(|ui| {
-                            if ui.button("New Empty Entity").clicked() {
-                                let label = Editor::unique_label_for_world(self.world, "Blank Entity");
-                                self.world.spawn((label,));
-                                ui.close();
-                            }
-                            ui.menu_button("Import Template", |_| {
-
-                            });
-                        }),
-                );
+                    builder.node(
+                        // root node/scene display
+                        NodeBuilder::dir(u64::MAX)
+                            .label(format!("Scene: {}", current_scene_name))
+                            .context_menu(|ui| {
+                                if ui.button("New Empty Entity").clicked() {
+                                    let label = Editor::unique_label_for_world(self.world, "Blank Entity");
+                                    self.world.spawn((label,));
+                                    ui.close();
+                                }
+                                ui.menu_button("Import Template", |_| {});
+                                ui.separator();
+                                if ui.button("Paste to Root").clicked() {
+                                    let copied = self.signal.iter().find_map(|s| {
+                                        if let Signal::Copy(e, pm) = s { Some((e.clone(), pm.clone())) } else { None }
+                                    });
+                                    if let Some((entities, parent_map)) = copied {
+                                        self.signal.push_back(Signal::Paste(entities, parent_map, None));
+                                    }
+                                    ui.close();
+                                }
+                            }),
+                    );
                 // the root scene must be the biggest number possible to remove any ambiguity
 
                 fn add_entity_to_tree(
@@ -125,6 +133,25 @@ impl<'a> EditorTabViewer<'a> {
                                 if ui.button("Create Template").clicked() {
                                     eucalyptus_core::fatal!("Not implemented yet");
                                 }
+                                ui.separator();
+                                if ui.button("Copy").clicked() {
+                                    let (sub_e, sub_pm) = Editor::collect_entity_subtree(world, entity, registry);
+                                    if !sub_e.is_empty() {
+                                        signal.retain(|s| !matches!(s, Signal::Copy(_, _)));
+                                        signal.push_back(Signal::Copy(sub_e, sub_pm));
+                                    }
+                                    ui.close();
+                                }
+                                if ui.button("Paste as Child").clicked() {
+                                    let copied = signal.iter().find_map(|s| {
+                                        if let Signal::Copy(e, pm) = s { Some((e.clone(), pm.clone())) } else { None }
+                                    });
+                                    if let Some((entities, parent_map)) = copied {
+                                        let paste_parent = world.get::<&Label>(entity).ok().map(|l| (*l).clone());
+                                        signal.push_back(Signal::Paste(entities, parent_map, paste_parent));
+                                    }
+                                    ui.close();
+                                }
                             }),
                     );
 
diff --git a/crates/eucalyptus-editor/src/editor/docks/viewport.rs b/crates/eucalyptus-editor/src/editor/docks/viewport.rs
index b3328b4..eac63d0 100644
--- a/crates/eucalyptus-editor/src/editor/docks/viewport.rs
+++ b/crates/eucalyptus-editor/src/editor/docks/viewport.rs
@@ -282,7 +282,7 @@ impl<'a> EditorTabViewer<'a> {
             let mut handled = false;
             let mut updated_light_transform: Option<Transform> = None;
 
-            if let Ok(mut entity_transform) = self.world.get::<&mut EntityTransform>(*entity_id) {
+            let entity_transform = if let Ok(entity_transform) = self.world.get::<&mut EntityTransform>(*entity_id) {
                 let was_focused = cfg.is_focused;
                 cfg.is_focused = self.gizmo.is_focused();
 
@@ -290,13 +290,22 @@ impl<'a> EditorTabViewer<'a> {
                     cfg.entity_transform_original = Some(*entity_transform);
                 }
 
-                let synced = entity_transform.propagate(&self.world, *entity_id);
-                let gizmo_transform =
-                    transform_gizmo_egui::math::Transform::from_scale_rotation_translation(
-                        synced.scale,
-                        synced.rotation,
-                        synced.position,
-                    );
+                entity_transform.clone()
+            } else {
+                EntityTransform::default()
+            };
+
+            let synced = entity_transform.propagate(&self.world, *entity_id);
+            let gizmo_transform =
+                transform_gizmo_egui::math::Transform::from_scale_rotation_translation(
+                    synced.scale,
+                    synced.rotation,
+                    synced.position,
+                );
+
+            if let Ok(mut entity_transform) = self.world.get::<&mut EntityTransform>(*entity_id) {
+                let was_focused = cfg.is_focused;
+                cfg.is_focused = self.gizmo.is_focused();
 
                 if let Some((_result, new_transforms)) = self.gizmo.interact(ui, &[gizmo_transform])
                     && let Some(new_transform) = new_transforms.first()
@@ -363,6 +372,7 @@ impl<'a> EditorTabViewer<'a> {
                 handled = true;
             }
 
+
             if !handled {
                 if let Ok(transform) = self.world.query_one::<&mut Transform>(*entity_id).get() {
                     let was_focused = cfg.is_focused;
diff --git a/crates/eucalyptus-editor/src/editor/input.rs b/crates/eucalyptus-editor/src/editor/input.rs
index c3615de..3c82bdf 100644
--- a/crates/eucalyptus-editor/src/editor/input.rs
+++ b/crates/eucalyptus-editor/src/editor/input.rs
@@ -171,23 +171,48 @@ impl Keyboard for Editor {
                             .id_for_title("Model/Entity List")
                             .map_or(false, |id| *tab == id)
                     {
-                        if let Some(entity) = &self.selected_entity {
-                            let entity = *entity;
-                            let (entities, parent_map) = Editor::collect_entity_subtree(
-                                self.world.as_ref(),
-                                entity,
-                                &self.component_registry,
-                            );
-                            if entities.is_empty() {
+                        use eucalyptus_core::hierarchy::Parent;
+                        let entities_to_copy: Vec<Entity> = if !self.selected_entities.is_empty() {
+                            self.selected_entities.clone()
+                        } else if let Some(e) = self.selected_entity {
+                            vec![e]
+                        } else {
+                            vec![]
+                        };
+                        if entities_to_copy.is_empty() {
+                            warn!("Unable to copy entity: None selected");
+                        } else {
+                            let sel_bits: HashSet<u64> = entities_to_copy.iter()
+                                .map(|e| e.to_bits().get()).collect();
+                            let mut all_entities = Vec::new();
+                            let mut all_parent_map = HashMap::new();
+                            let mut seen_labels: HashSet<String> = HashSet::new();
+                            for entity in &entities_to_copy {
+                                let mut skip = false;
+                                let mut cur = *entity;
+                                while let Ok(p) = self.world.get::<&Parent>(cur) {
+                                    let pe = p.parent();
+                                    if sel_bits.contains(&pe.to_bits().get()) { skip = true; break; }
+                                    cur = pe;
+                                }
+                                if skip { continue; }
+                                let (sub_e, sub_pm) = Editor::collect_entity_subtree(
+                                    self.world.as_ref(), *entity, &self.component_registry);
+                                for se in sub_e {
+                                    if seen_labels.insert(se.label.to_string()) {
+                                        all_entities.push(se);
+                                    }
+                                }
+                                all_parent_map.extend(sub_pm);
+                            }
+                            if all_entities.is_empty() {
                                 warn!("Unable to copy entity: Unable to obtain label");
                             } else {
                                 self.signal.retain(|s| !matches!(s, Signal::Copy(_, _)));
-                                self.signal.push_back(Signal::Copy(entities, parent_map));
-                                info!("Copied!");
-                                log::debug!("Copied selected entity");
+                                self.signal.push_back(Signal::Copy(all_entities, all_parent_map));
+                                info!("Copied {} entity(ies)!", entities_to_copy.len());
+                                log::debug!("Copied selected entities");
                             }
-                        } else {
-                            warn!("Unable to copy entity: None selected");
                         }
                     }
                 } else if matches!(self.viewport_mode, ViewportMode::Gizmo) {
@@ -204,7 +229,9 @@ impl Keyboard for Editor {
                     {
                         let entities = entities.clone();
                         let parent_map = parent_map.clone();
-                        self.signal.push_back(Signal::Paste(entities, parent_map));
+                        let paste_parent = self.selected_entity
+                            .and_then(|e| self.world.get::<&Label>(e).ok().map(|l| (*l).clone()));
+                        self.signal.push_back(Signal::Paste(entities, parent_map, paste_parent));
                     }
                 } else {
                     self.input_state.pressed_keys.insert(key);
diff --git a/crates/eucalyptus-editor/src/editor/mod.rs b/crates/eucalyptus-editor/src/editor/mod.rs
index e36a90b..1a02a09 100644
--- a/crates/eucalyptus-editor/src/editor/mod.rs
+++ b/crates/eucalyptus-editor/src/editor/mod.rs
@@ -38,11 +38,8 @@ use dropbear_engine::{
 use egui::{self, Ui};
 use egui_dock::{DockArea, DockState, NodeIndex, Style};
 use eucalyptus_core::component::{ComponentRegistry, SerializedComponent};
-use eucalyptus_core::hierarchy::{Children, SceneHierarchy};
+use eucalyptus_core::hierarchy::{Children, Parent, SceneHierarchy};
 use eucalyptus_core::physics::PhysicsState;
-use eucalyptus_core::physics::collider::shader::ColliderInstanceRaw;
-use eucalyptus_core::physics::collider::shader::ColliderWireframePipeline;
-use eucalyptus_core::physics::collider::{ColliderShapeKey, WireframeGeometry};
 use eucalyptus_core::scene::{SceneConfig, SceneEntity};
 use eucalyptus_core::states::Label;
 use eucalyptus_core::{APP_INFO, register_components};
@@ -88,8 +85,6 @@ pub struct Editor {
     pub size: Extent3d,
     pub instance_buffer_cache: HashMap<u64, ResizableBuffer<InstanceRaw>>,
     pub animated_instance_buffers: HashMap<Entity, ResizableBuffer<InstanceRaw>>,
-    pub collider_wireframe_geometry_cache: HashMap<ColliderShapeKey, WireframeGeometry>,
-    pub collider_instance_buffer: Option<ResizableBuffer<ColliderInstanceRaw>>,
     pub color: Color,
 
     pub ui_editor: UiEditor,
@@ -100,7 +95,6 @@ pub struct Editor {
     pub light_cube_pipeline: Option<LightCubePipeline>,
     pub main_render_pipeline: Option<MainRenderPipeline>,
     pub shader_globals: Option<GlobalsUniform>,
-    pub collider_wireframe_pipeline: Option<ColliderWireframePipeline>,
     pub mipmapper: Option<MipMapper>,
     pub sky_pipeline: Option<SkyPipeline>,
     pub billboard_pipeline: Option<BillboardPipeline>,
@@ -125,6 +119,8 @@ pub struct Editor {
 
     pub active_camera: Arc<Mutex<Option<Entity>>>,
 
+    pub selected_entities: Vec<Entity>,
+
     pub is_viewport_focused: bool,
     // is_cursor_locked: bool,
     pub window: Option<Arc<Window>>,
@@ -208,6 +204,8 @@ pub struct Editor {
 
     pub(crate) asset_clipboard: Option<AssetClipboard>,
     pub(crate) pending_aa_reload: Option<AntiAliasingMode>,
+
+    pub(crate) last_active_camera_for_per_frame: Option<hecs::Entity>,
 }
 
 impl Editor {
@@ -295,6 +293,7 @@ impl Editor {
             input_state: Box::new(InputState::new()),
             light_cube_pipeline: None,
             active_camera: Arc::new(Mutex::new(None)),
+            selected_entities: Vec::new(),
             progress_tx: None,
             is_world_loaded: IsWorldLoadedYet::new(),
             current_state: WorldLoadingStatus::Idle,
@@ -326,11 +325,8 @@ impl Editor {
             play_mode_exit_rx: None,
             asset_clipboard: None,
             pending_aa_reload: None,
-            collider_wireframe_pipeline: None,
             instance_buffer_cache: HashMap::new(),
             animated_instance_buffers: HashMap::new(),
-            collider_wireframe_geometry_cache: HashMap::new(),
-            collider_instance_buffer: None,
             mipmapper: None,
             sky_pipeline: None,
             billboard_pipeline: None,
@@ -348,10 +344,19 @@ impl Editor {
             current_page: EditorTabVisibility::GameEditor,
             ui_editor: UiEditor::new(),
             last_morph_info_per_mesh: Default::default(),
+            last_active_camera_for_per_frame: None,
         })
     }
 
     pub(crate) fn unique_label_for_world(world: &World, base: &str) -> Label {
+        Self::unique_label_for_world_with_extra(world, base, &HashSet::new())
+    }
+
+    pub(crate) fn unique_label_for_world_with_extra(
+        world: &World,
+        base: &str,
+        extra_taken: &HashSet<String>,
+    ) -> Label {
         fn parse_suffix(name: &str) -> Option<(&str, u32)> {
             let mut parts = name.rsplitn(2, '.');
             let suffix = parts.next()?;
@@ -368,6 +373,13 @@ impl Editor {
         for (_, label) in world.query::<(Entity, &Label)>().iter() {
             existing.insert(label.as_str().to_string());
         }
+        existing.extend(extra_taken.iter().cloned());
+        {
+            let pending = crate::spawn::PENDING_SPAWNS.lock();
+            for spawn in pending.iter() {
+                existing.insert(spawn.scene_entity.label.as_str().to_string());
+            }
+        }
 
         if !existing.contains(base) {
             return Label::new(base);
@@ -1100,22 +1112,46 @@ impl Editor {
                     });
                     ui.menu_button("Edit", |ui| {
                     if ui.button("Copy").clicked() {
-                        if let Some(entity) = &self.selected_entity {
-                            let entity = *entity;
-                            let (entities, parent_map) = Editor::collect_entity_subtree(
-                                self.world.as_ref(),
-                                entity,
-                                &self.component_registry,
-                            );
-                            if entities.is_empty() {
+                        let entities_to_copy: Vec<Entity> = if !self.selected_entities.is_empty() {
+                            self.selected_entities.clone()
+                        } else if let Some(e) = self.selected_entity {
+                            vec![e]
+                        } else {
+                            vec![]
+                        };
+                        if entities_to_copy.is_empty() {
+                            warn!("Unable to copy entity: None selected");
+                        } else {
+                            let sel_bits: HashSet<u64> = entities_to_copy.iter()
+                                .map(|e| e.to_bits().get()).collect();
+                            let mut all_entities = Vec::new();
+                            let mut all_parent_map = HashMap::new();
+                            let mut seen_labels: HashSet<String> = HashSet::new();
+                            for entity in &entities_to_copy {
+                                let mut skip = false;
+                                let mut cur = *entity;
+                                while let Ok(p) = self.world.get::<&Parent>(cur) {
+                                    let pe = p.parent();
+                                    if sel_bits.contains(&pe.to_bits().get()) { skip = true; break; }
+                                    cur = pe;
+                                }
+                                if skip { continue; }
+                                let (sub_e, sub_pm) = Editor::collect_entity_subtree(
+                                    self.world.as_ref(), *entity, &self.component_registry);
+                                for se in sub_e {
+                                    if seen_labels.insert(se.label.to_string()) {
+                                        all_entities.push(se);
+                                    }
+                                }
+                                all_parent_map.extend(sub_pm);
+                            }
+                            if all_entities.is_empty() {
                                 warn!("Unable to copy entity: Unable to obtain label");
                             } else {
                                 self.signal.retain(|s| !matches!(s, Signal::Copy(_, _)));
-                                self.signal.push_back(Signal::Copy(entities, parent_map));
-                                info!("Copied selected entity!");
+                                self.signal.push_back(Signal::Copy(all_entities, all_parent_map));
+                                info!("Copied {} entity(ies)!", entities_to_copy.len());
                             }
-                        } else {
-                            warn!("Unable to copy entity: None selected");
                         }
                     }
 
@@ -1130,7 +1166,9 @@ impl Editor {
 
                         match clipboard {
                             Some((entities, parent_map)) => {
-                                self.signal.push_back(Signal::Paste(entities, parent_map));
+                                let paste_parent = self.selected_entity
+                                    .and_then(|e| self.world.get::<&Label>(e).ok().map(|l| (*l).clone()));
+                                self.signal.push_back(Signal::Paste(entities, parent_map, paste_parent));
                             }
                             None => {
                                 warn!("Unable to paste: You haven't selected anything!");
@@ -1421,6 +1459,7 @@ impl Editor {
                     tex_size: self.size,
                     world: &mut self.world,
                     selected_entity: &mut self.selected_entity,
+                    selected_entities: &mut self.selected_entities,
                     viewport_mode: &mut self.viewport_mode,
                     undo_stack: &mut self.undo_stack,
                     signal: &mut self.signal,
@@ -1608,7 +1647,6 @@ impl Editor {
             graphics.clone(),
             Some("editor shader globals"),
         ));
-        self.collider_wireframe_pipeline = Some(ColliderWireframePipeline::new(graphics.clone()));
         self.mipmapper = None;
         self.billboard_pipeline = Some(BillboardPipeline::new(graphics.clone()));
         *graphics.debug_draw.lock() =
@@ -1910,10 +1948,9 @@ impl UndoableAction {
 pub enum Signal {
     #[default]
     None,
-    /// Carries the entity subtree to be pasted: a flat list of entities (root first, DFS order)
-    /// and a map of child_label -> parent_label within the subtree.
     Copy(Vec<SceneEntity>, HashMap<Label, Label>),
-    Paste(Vec<SceneEntity>, HashMap<Label, Label>),
+
+    Paste(Vec<SceneEntity>, HashMap<Label, Label>, Option<Label>),
     AssetCopy {
         source: PathBuf,
         division: AssetDivision,
diff --git a/crates/eucalyptus-editor/src/editor/scene.rs b/crates/eucalyptus-editor/src/editor/scene.rs
index fe06134..83adfdb 100644
--- a/crates/eucalyptus-editor/src/editor/scene.rs
+++ b/crates/eucalyptus-editor/src/editor/scene.rs
@@ -16,11 +16,11 @@ use egui::UiBuilder;
 use eucalyptus_core::billboard::BillboardComponent;
 use eucalyptus_core::component::KotlinComponentDecl;
 use eucalyptus_core::entity_status::EntityStatus;
-use eucalyptus_core::physics::collider::ColliderGroup;
-use eucalyptus_core::physics::collider::ColliderShapeKey;
-use eucalyptus_core::physics::collider::shader::{ColliderInstanceRaw, create_wireframe_geometry};
+use eucalyptus_core::hierarchy::EntityTransformExt;
+use eucalyptus_core::physics::collider::{ColliderGroup, ColliderShape};
 use eucalyptus_core::properties::CustomProperties;
 use eucalyptus_core::states::{Label, SCENES, WorldLoadingStatus};
+use eucalyptus_core::transform::OnRails;
 use eucalyptus_core::ui::HUDComponent;
 use hecs::Entity;
 use kino_ui::rendering::KinoRenderTargetId;
@@ -201,6 +201,17 @@ impl Scene for Editor {
             }
         }
 
+        {
+            for (rails, et) in
+                self.world.query_mut::<(&mut OnRails, &mut EntityTransform)>()
+            {
+                if let Some((pos, rot)) = rails.pending_transform.take() {
+                    et.world_mut().position = pos;
+                    et.world_mut().rotation = rot;
+                }
+            }
+        }
+
         self.component_registry.update_components(
             self.world.as_mut(),
             &mut self.physics_state,
@@ -535,7 +546,6 @@ impl Scene for Editor {
 
         self.static_batches.clear();
         self.animated_instances.clear();
-
         {
             puffin::profile_scope!("finding all renderers and animation components");
             let mut query = self
@@ -725,6 +735,23 @@ impl Scene for Editor {
             }
         }
 
+        if self.last_active_camera_for_per_frame != Some(active_camera) {
+            self.last_active_camera_for_per_frame = Some(active_camera);
+            if let (Some(pipeline), Some(globals), Some(light_pipeline)) = (
+                self.main_render_pipeline.as_mut(),
+                self.shader_globals.as_ref(),
+                self.light_cube_pipeline.as_ref(),
+            ) {
+                pipeline.per_frame = None;
+                pipeline.per_frame_bind_group(
+                    graphics.clone(),
+                    globals.buffer.buffer(),
+                    camera.buffer(),
+                    light_pipeline.light_buffer(),
+                );
+            }
+        }
+
         let sky = self
             .sky_pipeline
             .as_ref()
@@ -1064,7 +1091,7 @@ impl Scene for Editor {
             render_pass.draw(0..3, 0..1);
         }
 
-        // collider pipeline
+        // collider debug draw
         {
             let show_hitboxes = self
                 .current_scene_name
@@ -1079,118 +1106,87 @@ impl Scene for Editor {
                 .unwrap_or(false);
 
             if show_hitboxes {
-                puffin::profile_scope!("collider wireframe pipeline");
-                if let Some(collider_pipeline) = &self.collider_wireframe_pipeline {
-                    log_once::debug_once!("Found collider wireframe pipeline");
-                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                        label: Some("collider wireframe render pass"),
-                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                            view: hdr.render_view(),
-                            depth_slice: None,
-                            resolve_target: hdr.resolve_target(),
-                            ops: wgpu::Operations {
-                                load: wgpu::LoadOp::Load,
-                                store: wgpu::StoreOp::Store,
-                            },
-                        })],
-                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
-                            view: &graphics.depth_texture.view,
-                            depth_ops: Some(wgpu::Operations {
-                                load: wgpu::LoadOp::Load,
-                                store: wgpu::StoreOp::Store,
-                            }),
-                            stencil_ops: None,
-                        }),
-                        occlusion_query_set: None,
-                        timestamp_writes: None,
-                        multiview_mask: None,
-                    });
-
-                    render_pass.set_pipeline(&collider_pipeline.pipeline);
-                    render_pass.set_bind_group(0, Some(&camera.bind_group), &[]);
-
-                    let mut instances_by_shape: HashMap<
-                        ColliderShapeKey,
-                        Vec<ColliderInstanceRaw>,
-                    > = HashMap::new();
-
-                    let mut q = self.world.query::<(&EntityTransform, &ColliderGroup)>();
-                    for (entity_transform, group) in q.iter() {
-                        for collider in &group.colliders {
-                            let world_tf = entity_transform.sync();
-
+                puffin::profile_scope!("collider debug draw");
+                if let Some(debug_draw) = graphics.debug_draw.lock().as_mut() {
+                    let colour = [0.0, 1.0, 0.0, 1.0];
+                    let to_draw: Vec<_> = {
+                        let mut q = self.world.query::<(Entity, &ColliderGroup)>();
+                        q.iter().map(|(e, cg)| (e, cg.colliders.clone())).collect()
+                    };
+                    for (entity, colliders) in to_draw {
+                        let Ok(et) = self.world.get::<&EntityTransform>(entity) else {
+                            continue;
+                        };
+                        let world_tf = et.propagate(&self.world, entity);
+                        drop(et);
+                        for collider in &colliders {
                             let entity_matrix = world_tf.matrix().as_mat4();
-
                             let offset_transform = Transform::new()
                                 .with_offset(collider.translation, collider.rotation);
                             let offset_matrix = offset_transform.matrix().as_mat4();
-
                             let final_matrix = entity_matrix * offset_matrix;
-
-                            let color = [0.0, 1.0, 0.0, 1.0];
-                            let instance = ColliderInstanceRaw::from_matrix(final_matrix, color);
-
-                            let key = ColliderShapeKey::from(&collider.shape);
-                            instances_by_shape.entry(key).or_default().push(instance);
-
-                            self.collider_wireframe_geometry_cache
-                                .entry(key)
-                                .or_insert_with(|| {
-                                    create_wireframe_geometry(graphics.clone(), &collider.shape)
-                                });
-                        }
-                    }
-
-                    if !instances_by_shape.is_empty() {
-                        let total_instances: usize =
-                            instances_by_shape.values().map(|v| v.len()).sum();
-                        let mut all_instances = Vec::with_capacity(total_instances);
-                        let mut draws: Vec<(ColliderShapeKey, usize, usize)> = Vec::new();
-
-                        for (key, instances) in instances_by_shape {
-                            let start = all_instances.len();
-                            all_instances.extend_from_slice(&instances);
-                            let count = instances.len();
-                            draws.push((key, start, count));
-                        }
-
-                        let instance_buffer =
-                            self.collider_instance_buffer.get_or_insert_with(|| {
-                                ResizableBuffer::new(
-                                    &graphics.device,
-                                    all_instances.len().max(10),
-                                    wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                                    "Collider Instance Buffer",
-                                )
-                            });
-                        instance_buffer.write(&graphics.device, &graphics.queue, &all_instances);
-
-                        for (key, start, count) in draws {
-                            let Some(geometry) = self.collider_wireframe_geometry_cache.get(&key)
-                            else {
-                                continue;
-                            };
-
-                            let start_bytes = (start * std::mem::size_of::<ColliderInstanceRaw>())
-                                as wgpu::BufferAddress;
-                            let end_bytes = ((start + count)
-                                * std::mem::size_of::<ColliderInstanceRaw>())
-                                as wgpu::BufferAddress;
-
-                            render_pass.set_vertex_buffer(
-                                1,
-                                instance_buffer.buffer().slice(start_bytes..end_bytes),
-                            );
-                            render_pass.set_vertex_buffer(0, geometry.vertex_buffer.slice(..));
-                            render_pass.set_index_buffer(
-                                geometry.index_buffer.slice(..),
-                                wgpu::IndexFormat::Uint16,
-                            );
-                            render_pass.draw_indexed(0..geometry.index_count, 0, 0..count as u32);
+                            let (scale, rotation, translation) =
+                                final_matrix.to_scale_rotation_translation();
+
+                            match &collider.shape {
+                                ColliderShape::Box { half_extents } => {
+                                    let he = glam::Vec3::new(
+                                        half_extents.x as f32 * scale.x,
+                                        half_extents.y as f32 * scale.y,
+                                        half_extents.z as f32 * scale.z,
+                                    );
+                                    debug_draw.draw_obb(translation, he, rotation, colour);
+                                }
+                                ColliderShape::Sphere { radius } => {
+                                    let r = radius * scale.x.max(scale.y).max(scale.z);
+                                    debug_draw.draw_sphere(translation, r, colour);
+                                }
+                                ColliderShape::Capsule {
+                                    half_height,
+                                    radius,
+                                } => {
+                                    let axis = rotation * glam::Vec3::Y;
+                                    let top = translation + axis * (half_height * scale.y);
+                                    let bottom = translation - axis * (half_height * scale.y);
+                                    debug_draw.draw_capsule(
+                                        bottom,
+                                        top,
+                                        radius * scale.x.max(scale.z),
+                                        colour,
+                                    );
+                                }
+                                ColliderShape::Cylinder {
+                                    half_height,
+                                    radius,
+                                } => {
+                                    let axis = rotation * glam::Vec3::Y;
+                                    debug_draw.draw_cylinder(
+                                        translation,
+                                        half_height * scale.y,
+                                        radius * scale.x.max(scale.z),
+                                        axis,
+                                        colour,
+                                    );
+                                }
+                                ColliderShape::Cone {
+                                    half_height,
+                                    radius,
+                                } => {
+                                    let axis = rotation * glam::Vec3::Y;
+                                    let apex = translation + axis * (half_height * scale.y);
+                                    let height = 2.0 * half_height * scale.y;
+                                    let r = radius * scale.x.max(scale.z);
+                                    debug_draw.draw_cone(
+                                        apex,
+                                        -(rotation * glam::Vec3::Y),
+                                        (r / height).atan(),
+                                        height,
+                                        colour,
+                                    );
+                                }
+                            }
                         }
                     }
-                } else {
-                    log_once::warn_once!("No collider pipeline found");
                 }
             }
         }
diff --git a/crates/eucalyptus-editor/src/signal.rs b/crates/eucalyptus-editor/src/signal.rs
index cba9d71..72a2690 100644
--- a/crates/eucalyptus-editor/src/signal.rs
+++ b/crates/eucalyptus-editor/src/signal.rs
@@ -97,23 +97,25 @@ impl SignalController for Editor {
 
                     Ok(())
                 }
-                Signal::Paste(entities, parent_map) => {
+                Signal::Paste(entities, parent_map, paste_parent) => {
                     log::debug!("Paste requested for {} entity(ies)", entities.len());
 
-                    // Rename all entities to ensure uniqueness, tracking old→new label mapping.
+
                     let mut label_remap: HashMap<Label, Label> = HashMap::new();
                     let mut renamed: Vec<SceneEntity> = Vec::new();
+                    let mut batch_taken: HashSet<String> = HashSet::new();
                     for mut scene_entity in entities {
-                        let new_label = Editor::unique_label_for_world(
+                        let new_label = Editor::unique_label_for_world_with_extra(
                             self.world.as_ref(),
                             scene_entity.label.as_str(),
+                            &batch_taken,
                         );
+                        batch_taken.insert(new_label.as_str().to_string());
                         label_remap.insert(scene_entity.label.clone(), new_label.clone());
                         scene_entity.label = new_label;
                         renamed.push(scene_entity);
                     }
 
-                    // Rebuild parent map with renamed labels.
                     let renamed_parent_map: HashMap<Label, Label> = parent_map
                         .into_iter()
                         .filter_map(|(child, parent)| {
@@ -123,13 +125,15 @@ impl SignalController for Editor {
                         })
                         .collect();
 
-                    // Keep clipboard alive so the user can paste again.
                     self.signal
                         .push_back(Signal::Copy(renamed.clone(), renamed_parent_map.clone()));
 
-                    // Queue a PendingSpawn for each entity, with parent_label set as needed.
+
                     for scene_entity in renamed {
-                        let parent_label = renamed_parent_map.get(&scene_entity.label).cloned();
+                        let parent_label = renamed_parent_map
+                            .get(&scene_entity.label)
+                            .cloned()
+                            .or_else(|| paste_parent.clone());
                         push_pending_spawn(PendingSpawn {
                             scene_entity,
                             handle: None,
@@ -676,7 +680,6 @@ impl SignalController for Editor {
                     self.main_render_pipeline = None;
                     self.light_cube_pipeline = None;
                     self.shader_globals = None;
-                    self.collider_wireframe_pipeline = None;
                     self.mipmapper = None;
                     self.texture_id = None;
                     self.window = None;
diff --git a/crates/redback-runtime/src/lib.rs b/crates/redback-runtime/src/lib.rs
index 7cef9ba..f3183de 100644
--- a/crates/redback-runtime/src/lib.rs
+++ b/crates/redback-runtime/src/lib.rs
@@ -156,6 +156,8 @@ pub struct PlayMode {
     pub(crate) animated_bind_group_cache: HashMap<Entity, (u64, wgpu::BindGroup)>,
     pub(crate) last_morph_info_per_mesh: HashMap<u32, MorphTargetInfo>,
 
+    last_active_camera_for_per_frame: Option<Entity>,
+
     initial_scene: Option<String>,
     current_scene: Option<String>,
     world_loading_progress: Option<Receiver<WorldLoadingStatus>>,
@@ -246,6 +248,7 @@ impl PlayMode {
             animated_instances: vec![],
             animated_bind_group_cache: Default::default(),
             last_morph_info_per_mesh: Default::default(),
+            last_active_camera_for_per_frame: None,
         };
 
         log::debug!("Created new play mode instance");
diff --git a/crates/redback-runtime/src/scene.rs b/crates/redback-runtime/src/scene.rs
index 764b363..d31e66f 100644
--- a/crates/redback-runtime/src/scene.rs
+++ b/crates/redback-runtime/src/scene.rs
@@ -406,10 +406,6 @@ impl Scene for PlayMode {
             graphics.clone(),
         );
 
-        if let Some(l) = &mut self.light_cube_pipeline {
-            l.update(graphics.clone(), &self.world);
-        }
-
         let mut ui = egui::Ui::new(graphics.get_egui_context(), egui::Id::new("redback-runtime ui"), egui::UiBuilder::default());
 
         #[cfg(feature = "debug")]
@@ -696,7 +692,6 @@ impl Scene for PlayMode {
 
         self.static_batches.clear();
         self.animated_instances.clear();
-
         {
             puffin::profile_scope!("finding all renderers and animation components");
             let mut query = self
@@ -887,6 +882,23 @@ impl Scene for PlayMode {
             }
         }
 
+        if self.last_active_camera_for_per_frame != Some(active_camera) {
+            self.last_active_camera_for_per_frame = Some(active_camera);
+            if let (Some(pipeline), Some(globals), Some(light_pipeline)) = (
+                self.main_pipeline.as_mut(),
+                self.shader_globals.as_ref(),
+                self.light_cube_pipeline.as_ref(),
+            ) {
+                pipeline.per_frame = None;
+                pipeline.per_frame_bind_group(
+                    graphics.clone(),
+                    globals.buffer.buffer(),
+                    camera.buffer(),
+                    light_pipeline.light_buffer(),
+                );
+            }
+        }
+
         let sky = self
             .sky_pipeline
             .as_ref()
@@ -1244,10 +1256,17 @@ impl Scene for PlayMode {
                 puffin::profile_scope!("collider debug draw");
                 if let Some(debug_draw) = graphics.debug_draw.lock().as_mut() {
                     let colour = [0.0, 1.0, 0.0, 1.0];
-                    let mut q = self.world.query::<(&EntityTransform, &ColliderGroup)>();
-                    for (entity_transform, group) in q.iter() {
-                        for collider in &group.colliders {
-                            let world_tf = entity_transform.sync();
+                    let to_draw: Vec<_> = {
+                        let mut q = self.world.query::<(Entity, &ColliderGroup)>();
+                        q.iter().map(|(e, cg)| (e, cg.colliders.clone())).collect()
+                    };
+                    for (entity, colliders) in to_draw {
+                        let Ok(et) = self.world.get::<&EntityTransform>(entity) else {
+                            continue;
+                        };
+                        let world_tf = et.propagate(&self.world, entity);
+                        drop(et);
+                        for collider in &colliders {
                             let entity_matrix = world_tf.matrix().as_mat4();
                             let offset_transform = Transform::new()
                                 .with_offset(collider.translation, collider.rotation);
diff --git a/include/dropbear.h b/include/dropbear.h
index a0cf32d..403d67a 100644
--- a/include/dropbear.h
+++ b/include/dropbear.h
@@ -655,8 +655,11 @@ int32_t dropbear_transform_on_rails_get_drive_type(WorldPtr world, uint64_t enti
 int32_t dropbear_transform_on_rails_get_enabled(WorldPtr world, uint64_t entity, bool* out0);
 int32_t dropbear_transform_on_rails_get_path_len(WorldPtr world, uint64_t entity, int32_t* out0);
 int32_t dropbear_transform_on_rails_get_path_point(WorldPtr world, uint64_t entity, int32_t index, NVector3* out0);
+int32_t dropbear_transform_on_rails_get_path_point_has_rotation(WorldPtr world, uint64_t entity, int32_t index, bool* out0);
+int32_t dropbear_transform_on_rails_get_path_point_rotation(WorldPtr world, uint64_t entity, int32_t index, NQuaternion* out0);
 int32_t dropbear_transform_on_rails_get_progress(WorldPtr world, uint64_t entity, float* out0);
 int32_t dropbear_transform_on_rails_push_path_point(WorldPtr world, uint64_t entity, const NVector3* point);
+int32_t dropbear_transform_on_rails_push_path_point_with_rotation(WorldPtr world, uint64_t entity, const NVector3* point, const NQuaternion* rotation);
 int32_t dropbear_transform_on_rails_set_drive_automatic(WorldPtr world, uint64_t entity, float speed, bool looping);
 int32_t dropbear_transform_on_rails_set_drive_axis_driven(WorldPtr world, uint64_t entity, uint64_t target, const NVector3* axis, float range_min, float range_max);
 int32_t dropbear_transform_on_rails_set_drive_follow_entity(WorldPtr world, uint64_t entity, uint64_t target, bool monotonic);
diff --git a/scripting/commonMain/kotlin/com/dropbear/components/camera/OnRails.kt b/scripting/commonMain/kotlin/com/dropbear/components/camera/OnRails.kt
index cdc5736..f726b86 100644
--- a/scripting/commonMain/kotlin/com/dropbear/components/camera/OnRails.kt
+++ b/scripting/commonMain/kotlin/com/dropbear/components/camera/OnRails.kt
@@ -3,7 +3,7 @@ package com.dropbear.components.camera
 import com.dropbear.EntityId
 import com.dropbear.ecs.ComponentType
 import com.dropbear.ecs.ExternalComponent
-import com.dropbear.math.Vector3d
+import com.dropbear.math.Point
 import com.dropbear.math.Vector3f
 
 /**
@@ -90,9 +90,10 @@ class OnRails(
     /**
      * The ordered list of world-space waypoints that define the rail.
      *
-     * Requires at least 2 points. Internally stored as `Vec<DVec3>` on the Rust side.
+     * Requires at least 2 points. Each [com.dropbear.math.Point] carries a position and an optional explicit
+     * rotation. When [com.dropbear.math.Point.rotation] is `null`, orientation is derived from the path tangent.
      */
-    var path: List<Vector3d>
+    var path: List<Point>
         get() = onRailsGetPath()
         set(value) = onRailsSetPath(value)
 
@@ -127,8 +128,8 @@ internal expect fun onRailsExistsForEntity(entityId: EntityId): Boolean
 internal expect fun OnRails.onRailsGetEnabled(): Boolean
 internal expect fun OnRails.onRailsSetEnabled(enabled: Boolean)
 
-internal expect fun OnRails.onRailsGetPath(): List<Vector3d>
-internal expect fun OnRails.onRailsSetPath(path: List<Vector3d>)
+internal expect fun OnRails.onRailsGetPath(): List<Point>
+internal expect fun OnRails.onRailsSetPath(path: List<Point>)
 
 internal expect fun OnRails.onRailsGetProgress(): Float
 internal expect fun OnRails.onRailsSetProgress(progress: Float)
diff --git a/scripting/commonMain/kotlin/com/dropbear/math/Point.kt b/scripting/commonMain/kotlin/com/dropbear/math/Point.kt
new file mode 100644
index 0000000..9ce3639
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/math/Point.kt
@@ -0,0 +1,13 @@
+package com.dropbear.math
+
+/**
+ * Defined a position and a rotation in world-space.
+ *
+ * @param position World-space position of the waypoint.
+ * @param rotation Explicit orientation to hold at this point. When `null`, the runtime derives
+ *   orientation from the path tangent (i.e., the camera looks in the direction of travel).
+ */
+data class Point(
+    val position: Vector3d,
+    val rotation: Quaterniond? = null,
+)
\ No newline at end of file
diff --git a/scripting/jvmMain/java/com/dropbear/EucalyptusCoreLoader.java b/scripting/jvmMain/java/com/dropbear/EucalyptusCoreLoader.java
index 1a27884..908da65 100644
--- a/scripting/jvmMain/java/com/dropbear/EucalyptusCoreLoader.java
+++ b/scripting/jvmMain/java/com/dropbear/EucalyptusCoreLoader.java
@@ -12,7 +12,9 @@ public class EucalyptusCoreLoader implements DynamicLibraryLoader {
     public void ensureLoaded() {
         if (!loaded) {
             Logger.info("Initialising \"eucalyptus_core\"", "EucalyptusCoreLoader::ensureLoaded");
-            System.loadLibrary("eucalyptus_core");
+            String path = System.getProperty("eucalyptus.core.lib");
+            if (path == null) throw new RuntimeException("eucalyptus.core.lib not set");
+            Runtime.getRuntime().load(path);
             Logger.info("Loaded!", "EucalyptusCoreLoader::ensureLoaded");
             loaded = true;
         }
diff --git a/scripting/jvmMain/java/com/dropbear/components/camera/OnRailsNative.java b/scripting/jvmMain/java/com/dropbear/components/camera/OnRailsNative.java
index 99c2430..fdfa647 100644
--- a/scripting/jvmMain/java/com/dropbear/components/camera/OnRailsNative.java
+++ b/scripting/jvmMain/java/com/dropbear/components/camera/OnRailsNative.java
@@ -18,8 +18,11 @@ public class OnRailsNative {
 
     public static native int getPathLen(long worldPtr, long entityId);
     public static native Vector3d getPathPoint(long worldPtr, long entityId, int index);
+    public static native boolean getPathPointHasRotation(long worldPtr, long entityId, int index);
+    public static native com.dropbear.math.Quaterniond getPathPointRotation(long worldPtr, long entityId, int index);
     public static native void clearPath(long worldPtr, long entityId);
     public static native void pushPathPoint(long worldPtr, long entityId, Vector3d point);
+    public static native void pushPathPointWithRotation(long worldPtr, long entityId, Vector3d point, com.dropbear.math.Quaterniond rotation);
 
     // 0=Automatic, 1=FollowEntity, 2=AxisDriven, 3=Manual
     public static native int getDriveType(long worldPtr, long entityId);
diff --git a/scripting/jvmMain/kotlin/com/dropbear/components/camera/OnRails.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/components/camera/OnRails.jvm.kt
index 267ae37..21df0c9 100644
--- a/scripting/jvmMain/kotlin/com/dropbear/components/camera/OnRails.jvm.kt
+++ b/scripting/jvmMain/kotlin/com/dropbear/components/camera/OnRails.jvm.kt
@@ -2,6 +2,7 @@ package com.dropbear.components.camera
 
 import com.dropbear.DropbearEngine
 import com.dropbear.EntityId
+import com.dropbear.math.Point
 import com.dropbear.math.Vector3d
 import com.dropbear.math.Vector3f
 
@@ -20,16 +21,29 @@ internal actual fun OnRails.onRailsGetProgress(): Float =
 internal actual fun OnRails.onRailsSetProgress(progress: Float) =
     OnRailsNative.setProgress(DropbearEngine.native.worldHandle, entity.raw, progress)
 
-internal actual fun OnRails.onRailsGetPath(): List<Vector3d> {
+internal actual fun OnRails.onRailsGetPath(): List<Point> {
     val world = DropbearEngine.native.worldHandle
     val len = OnRailsNative.getPathLen(world, entity.raw)
-    return (0 until len).map { i -> OnRailsNative.getPathPoint(world, entity.raw, i) }
+    return (0 until len).map { i ->
+        val pos = OnRailsNative.getPathPoint(world, entity.raw, i)
+        val rot = if (OnRailsNative.getPathPointHasRotation(world, entity.raw, i))
+            OnRailsNative.getPathPointRotation(world, entity.raw, i)
+        else
+            null
+        Point(pos, rot)
+    }
 }
 
-internal actual fun OnRails.onRailsSetPath(path: List<Vector3d>) {
+internal actual fun OnRails.onRailsSetPath(path: List<Point>) {
     val world = DropbearEngine.native.worldHandle
     OnRailsNative.clearPath(world, entity.raw)
-    path.forEach { p -> OnRailsNative.pushPathPoint(world, entity.raw, p) }
+    path.forEach { p ->
+        val rot = p.rotation
+        if (rot != null)
+            OnRailsNative.pushPathPointWithRotation(world, entity.raw, p.position, rot)
+        else
+            OnRailsNative.pushPathPoint(world, entity.raw, p.position)
+    }
 }
 
 internal actual fun OnRails.onRailsGetDrive(): RailDrive {
diff --git a/scripting/nativeMain/kotlin/com/dropbear/components/camera/OnRails.native.kt b/scripting/nativeMain/kotlin/com/dropbear/components/camera/OnRails.native.kt
index f19836a..070a0a4 100644
--- a/scripting/nativeMain/kotlin/com/dropbear/components/camera/OnRails.native.kt
+++ b/scripting/nativeMain/kotlin/com/dropbear/components/camera/OnRails.native.kt
@@ -5,6 +5,8 @@ package com.dropbear.components.camera
 import com.dropbear.DropbearEngine
 import com.dropbear.EntityId
 import com.dropbear.ffi.generated.*
+import com.dropbear.math.Point
+import com.dropbear.math.Quaterniond
 import com.dropbear.math.Vector3d
 import com.dropbear.math.Vector3f
 import kotlinx.cinterop.*
@@ -40,26 +42,42 @@ internal actual fun OnRails.onRailsSetProgress(progress: Float) = memScoped {
     dropbear_transform_on_rails_set_progress(world, entity.raw.toULong(), progress)
 }
 
-internal actual fun OnRails.onRailsGetPath(): List<Vector3d> = memScoped {
+internal actual fun OnRails.onRailsGetPath(): List<Point> = memScoped {
     val world = DropbearEngine.native.worldHandle ?: return@memScoped emptyList()
     val lenOut = alloc<IntVar>()
     val rc = dropbear_transform_on_rails_get_path_len(world, entity.raw.toULong(), lenOut.ptr)
     if (rc != 0) return@memScoped emptyList()
     val len = lenOut.value
     (0 until len).mapNotNull { i ->
-        val out = alloc<NVector3>()
-        val rc2 = dropbear_transform_on_rails_get_path_point(world, entity.raw.toULong(), i, out.ptr)
-        if (rc2 != 0) null else Vector3d(out.x, out.y, out.z)
+        val posOut = alloc<NVector3>()
+        val rc2 = dropbear_transform_on_rails_get_path_point(world, entity.raw.toULong(), i, posOut.ptr)
+        if (rc2 != 0) return@mapNotNull null
+        val pos = Vector3d(posOut.x, posOut.y, posOut.z)
+        val hasRotOut = alloc<BooleanVar>()
+        dropbear_transform_on_rails_get_path_point_has_rotation(world, entity.raw.toULong(), i, hasRotOut.ptr)
+        val rot = if (hasRotOut.value) {
+            val rotOut = alloc<NQuaternion>()
+            dropbear_transform_on_rails_get_path_point_rotation(world, entity.raw.toULong(), i, rotOut.ptr)
+            Quaterniond(rotOut.x, rotOut.y, rotOut.z, rotOut.w)
+        } else null
+        Point(pos, rot)
     }
 }
 
-internal actual fun OnRails.onRailsSetPath(path: List<Vector3d>) = memScoped {
+internal actual fun OnRails.onRailsSetPath(path: List<Point>) = memScoped {
     val world = DropbearEngine.native.worldHandle ?: return@memScoped
     dropbear_transform_on_rails_clear_path(world, entity.raw.toULong())
-    for (point in path) {
+    for (railPoint in path) {
         val v = alloc<NVector3>()
-        v.x = point.x; v.y = point.y; v.z = point.z
-        dropbear_transform_on_rails_push_path_point(world, entity.raw.toULong(), v.ptr)
+        v.x = railPoint.position.x; v.y = railPoint.position.y; v.z = railPoint.position.z
+        val rot = railPoint.rotation
+        if (rot != null) {
+            val q = alloc<NQuaternion>()
+            q.x = rot.x; q.y = rot.y; q.z = rot.z; q.w = rot.w
+            dropbear_transform_on_rails_push_path_point_with_rotation(world, entity.raw.toULong(), v.ptr, q.ptr)
+        } else {
+            dropbear_transform_on_rails_push_path_point(world, entity.raw.toULong(), v.ptr)
+        }
     }
 }