Skip to main content

kvas_engine/
mesh_edit.rs

1//! Editable static geometry, persisted in scenes and uploaded as versioned DynamicMesh data.
2use crate::{components::{Component, DynamicMesh}, mesh::{MeshData, Vertex}};
3use glam::Vec3;
4use kvas_ecs::World;
5use serde::{Deserialize, Serialize};
6use std::sync::Arc;
7
8/// Scene-persistent geometry. Increment revision after manual vertex/index changes.
9#[derive(Clone, Default, Serialize, Deserialize)]
10pub struct EditableMesh {
11    /// Position, normal and UV attributes, indexed by triangle data.
12    pub vertices: Vec<Vertex>,
13    /// Triangle-list topology.
14    pub indices: Vec<u32>,
15    /// Version of geometry; update increments refresh the shared GPU mesh.
16    pub revision: u64,
17}
18impl Component for EditableMesh {}
19
20impl EditableMesh {
21    /// Copies the geometry of a validated source. Material images remain on MeshRenderer.
22    pub fn from_mesh(mesh: &MeshData) -> Result<Self, String> {
23        mesh.validate()?;
24        if mesh.vertices.len() > 100_000 || mesh.indices.len() > 600_000 { return Err("editable geometry limit: 100000 vertices / 600000 indices".into()); }
25        Ok(Self { vertices: mesh.vertices.clone(), indices: mesh.indices.clone(), revision: 1 })
26    }
27    /// CPU mesh snapshot, with no imported material runs.
28    pub fn mesh(&self) -> MeshData { MeshData { vertices: self.vertices.clone(), indices: self.indices.clone(), ..Default::default() } }
29    /// Marks vertex/index modifications for upload.
30    pub fn changed(&mut self) { self.revision = self.revision.saturating_add(1); }
31    /// Rebuilds area-weighted normals after deformation.
32    pub fn recalculate_normals(&mut self) -> Result<(), String> {
33        let mut mesh = self.mesh(); mesh.recalculate_normals()?; self.vertices = mesh.vertices; self.changed(); Ok(())
34    }
35    /// Simple dominant-normal box projection. This is a UV inspection tool, not an island packer.
36    pub fn box_uv(&mut self, scale: f32) -> Result<(), String> {
37        if !scale.is_finite() || scale <= 0.0 { return Err("UV scale must be positive".into()); }
38        for v in &mut self.vertices {
39            let n = Vec3::from(v.normal).abs(); let p = Vec3::from(v.pos) / scale;
40            v.uv = if n.y >= n.x && n.y >= n.z { [p.x, p.z] } else if n.x >= n.z { [p.z, p.y] } else { [p.x, p.y] };
41        }
42        self.changed(); Ok(())
43    }
44    /// Pushes nearby vertices along their normals with a smooth local-space brush falloff.
45    /// Coincident split vertices share a direction to keep hard-edged meshes closed.
46    pub fn sculpt(&mut self, center: Vec3, radius: f32, amount: f32) -> bool {
47        if !center.is_finite() || !radius.is_finite() || radius <= 0.0 || !amount.is_finite() { return false; }
48        let key = |p: [f32; 3]| p.map(|v| (v as f64 * 100000.0).round() as i64);
49        let mut normals = std::collections::HashMap::<[i64; 3], Vec3>::new();
50        for v in &self.vertices { *normals.entry(key(v.pos)).or_default() += Vec3::from(v.normal); }
51        let mut changed = false;
52        for v in &mut self.vertices {
53            let p = Vec3::from(v.pos); let distance = p.distance(center) / radius;
54            if distance < 1.0 {
55                let falloff = (1.0 - distance * distance).powi(2);
56                v.pos = (p + normals[&key(v.pos)].normalize_or_zero() * amount * falloff).to_array();
57                changed = true;
58            }
59        }
60        if changed { self.changed(); } changed
61    }
62    /// OBJ with indexed positions, UVs and normals. Does not export glTF material bindings.
63    pub fn to_obj(&self) -> Result<String, String> {
64        use std::fmt::Write;
65        self.mesh().validate()?;
66        let mut text = String::from("# Kvas edited geometry; local coordinates\n");
67        for v in &self.vertices { writeln!(text, "v {} {} {}", v.pos[0], v.pos[1], v.pos[2]).unwrap(); }
68        for v in &self.vertices { writeln!(text, "vt {} {}", v.uv[0], v.uv[1]).unwrap(); }
69        for v in &self.vertices { writeln!(text, "vn {} {} {}", v.normal[0], v.normal[1], v.normal[2]).unwrap(); }
70        for t in self.indices.chunks_exact(3) { let a = t[0] + 1; let b = t[1] + 1; let c = t[2] + 1; writeln!(text, "f {a}/{a}/{a} {b}/{b}/{b} {c}/{c}/{c}").unwrap(); }
71        Ok(text)
72    }
73}
74
75/// Uploads changed editable geometry. Takes precedence over procedural geometry on the same entity.
76pub fn update(world: &mut World) {
77    for e in world.entities_with::<EditableMesh>() {
78        let Some(edit) = world.get::<EditableMesh>(e) else { continue };
79        // High bit separates authored geometry revisions from other procedural systems.
80        let version = edit.revision | (1u64 << 63);
81        if world.get::<DynamicMesh>(e).is_some_and(|d| d.version == version) { continue; }
82        let mesh = edit.mesh(); if mesh.validate().is_err() { continue; }
83        world.insert(e, DynamicMesh { data: Arc::new(mesh), version });
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    #[test]
91    fn sculpt_uv_export_and_scene_round_trip_preserve_geometry() {
92        let source = MeshData::primitive("cube").unwrap();
93        let mut editable = EditableMesh::from_mesh(&source).unwrap();
94        assert!(editable.sculpt(Vec3::new(0.5, 0.5, 0.5), 0.2, 0.1));
95        // Split vertices of the same corner receive identical displacement.
96        let a = Vec3::from(editable.vertices[0].pos);
97        assert!(a.is_finite());
98        editable.recalculate_normals().unwrap(); editable.box_uv(2.0).unwrap();
99        let json = serde_json::to_string(&editable).unwrap();
100        let restored: EditableMesh = serde_json::from_str(&json).unwrap();
101        assert_eq!(restored.to_obj().unwrap(), editable.to_obj().unwrap());
102        assert!(restored.to_obj().unwrap().contains("f "));
103        assert!(!editable.sculpt(Vec3::ZERO, 0.0, 1.0));
104    }
105}