diff --git a/lib/rbx_loader/src/union.rs b/lib/rbx_loader/src/union.rs
index a68bcc1..30afbbf 100644
--- a/lib/rbx_loader/src/union.rs
+++ b/lib/rbx_loader/src/union.rs
@@ -1,6 +1,5 @@
-use std::collections::HashMap;
-
-use strafesnet_common::model::{self, ColorId, IndexedVertex, NormalId, PolygonGroup, PolygonGroupId, PolygonList, PositionId, RenderConfigId, TextureCoordinateId, VertexId};
+use rbx_mesh::mesh_data::NormalId2 as MeshDataNormalId2;
+use strafesnet_common::model::{self,IndexedVertex,PolygonGroup,PolygonGroupId,PolygonList,RenderConfigId};
 use strafesnet_common::integer::vec3;
 
 #[allow(dead_code)]
@@ -19,6 +18,40 @@ impl std::fmt::Display for Error{
 	}
 }
 
+// wacky state machine to make sure all vertices in a face agree upon what NormalId to use.
+// Roblox duplicates this information per vertex when it should only exist per-face.
+enum MeshDataNormalStatus{
+	Agree(MeshDataNormalId2),
+	Conflicting,
+}
+struct MeshDataNormalChecker{
+	status:Option<MeshDataNormalStatus>,
+}
+impl MeshDataNormalChecker{
+	fn new()->Self{
+		Self{status:None}
+	}
+	fn check(&mut self,normal:MeshDataNormalId2){
+		self.status=match self.status.take(){
+			None=>Some(MeshDataNormalStatus::Agree(normal)),
+			Some(MeshDataNormalStatus::Agree(old_normal))=>{
+				if old_normal==normal{
+					Some(MeshDataNormalStatus::Agree(old_normal))
+				}else{
+					Some(MeshDataNormalStatus::Conflicting)
+				}
+			},
+			Some(MeshDataNormalStatus::Conflicting)=>Some(MeshDataNormalStatus::Conflicting),
+		};
+	}
+	fn into_agreed_normal(self)->Option<MeshDataNormalId2>{
+		self.status.and_then(|status|match status{
+			MeshDataNormalStatus::Agree(normal)=>Some(normal),
+			MeshDataNormalStatus::Conflicting=>None,
+		})
+	}
+}
+
 impl std::error::Error for Error{}
 pub fn convert(roblox_physics_data:&[u8],roblox_mesh_data:&[u8])->Result<model::Mesh,Error>{
 	match (roblox_physics_data,roblox_mesh_data){
@@ -52,10 +85,48 @@ pub fn convert(roblox_physics_data:&[u8],roblox_mesh_data:&[u8])->Result<model::
 		rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::PhysicsInfoMesh(pim))
 		=>vec![pim.mesh],
 	};
+
+	// build graphics and physics meshes
 	let mut mb=strafesnet_common::model::MeshBuilder::new();
+	// graphics
+	const NORMAL_FACES:usize=6;
+	let mut polygon_groups_normal_id=vec![Vec::new();NORMAL_FACES];
+	for [vertex_id0,vertex_id1,vertex_id2] in graphics_mesh.faces{
+		let face=[
+			graphics_mesh.vertices.get(vertex_id0.0 as usize).ok_or(Error::MissingVertexId(vertex_id0.0))?,
+			graphics_mesh.vertices.get(vertex_id1.0 as usize).ok_or(Error::MissingVertexId(vertex_id1.0))?,
+			graphics_mesh.vertices.get(vertex_id2.0 as usize).ok_or(Error::MissingVertexId(vertex_id2.0))?,
+		];
+		let mut normal_agreement_checker=MeshDataNormalChecker::new();
+		let face=face.into_iter().map(|vertex|{
+			normal_agreement_checker.check(vertex.normal_id);
+			let pos=mb.acquire_pos_id(vec3::try_from_f32_array(vertex.pos)?);
+			let normal=mb.acquire_normal_id(vec3::try_from_f32_array(vertex.norm)?);
+			let tex=mb.acquire_tex_id(glam::Vec2::from_array(vertex.tex));
+			let color=mb.acquire_color_id(glam::Vec4::from_array(vertex.color.map(|f|f as f32/255.0f32)));
+			Ok(mb.acquire_vertex_id(IndexedVertex{pos,tex,normal,color}))
+		}).collect::<Result<Vec<_>,_>>().map_err(Error::Planar64Vec3)?;
+		if let Some(normal_id)=normal_agreement_checker.into_agreed_normal(){
+			polygon_groups_normal_id[normal_id as usize-1].push(face);
+		}else{
+			panic!("Empty face!");
+		}
+	}
+	let graphics_groups=(0..polygon_groups_normal_id.len()).map(|polygon_group_id|{
+		model::IndexedGraphicsGroup{
+			render:RenderConfigId::new(0),
+			groups:vec![PolygonGroupId::new(polygon_group_id as u32)]
+		}
+	}).collect();
+
+	//physics
 	let color=mb.acquire_color_id(glam::Vec4::ONE);
 	let tex=mb.acquire_tex_id(glam::Vec2::ZERO);
-	let polygon_groups:Vec<PolygonGroup>=physics_convex_meshes.into_iter().map(|mesh|{
+	let polygon_groups:Vec<PolygonGroup>=polygon_groups_normal_id.into_iter().map(|faces|
+		// graphics polygon groups (to be rendered)
+		Ok(PolygonGroup::PolygonList(PolygonList::new(faces)))
+	).chain(physics_convex_meshes.into_iter().map(|mesh|{
+		// physics polygon groups (to do physics)
 		Ok(PolygonGroup::PolygonList(PolygonList::new(mesh.faces.into_iter().map(|[vertex_id0,vertex_id1,vertex_id2]|{
 			let face=[
 				mesh.vertices.get(vertex_id0.0 as usize).ok_or(Error::MissingVertexId(vertex_id0.0))?,
@@ -70,12 +141,8 @@ pub fn convert(roblox_physics_data:&[u8],roblox_mesh_data:&[u8])->Result<model::
 				Ok(mb.acquire_vertex_id(IndexedVertex{pos,tex,normal,color}))
 			}).collect()
 		}).collect::<Result<_,_>>()?)))
-	}).collect::<Result<_,_>>()?;
-	let graphics_groups=vec![model::IndexedGraphicsGroup{
-		render:RenderConfigId::new(0),
-		groups:(0..polygon_groups.len()).map(|id|PolygonGroupId::new(id as u32)).collect()
-	}];
-	let physics_groups=(0..polygon_groups.len()).map(|id|model::IndexedPhysicsGroup{
+	})).collect::<Result<_,_>>()?;
+	let physics_groups=(NORMAL_FACES..polygon_groups.len()).map(|id|model::IndexedPhysicsGroup{
 		groups:vec![PolygonGroupId::new(id as u32)]
 	}).collect();
 	Ok(mb.build(