diff --git a/lib/rbx_loader/src/lib.rs b/lib/rbx_loader/src/lib.rs
index bb73961..bc5f777 100644
--- a/lib/rbx_loader/src/lib.rs
+++ b/lib/rbx_loader/src/lib.rs
@@ -3,6 +3,7 @@ use rbx_dom_weak::WeakDom;
 
 mod rbx;
 mod mesh;
+mod union;
 mod primitives;
 
 pub mod data{
diff --git a/lib/rbx_loader/src/mesh.rs b/lib/rbx_loader/src/mesh.rs
index 4b18f56..2b3bb60 100644
--- a/lib/rbx_loader/src/mesh.rs
+++ b/lib/rbx_loader/src/mesh.rs
@@ -1,7 +1,7 @@
 use std::collections::HashMap;
 
-use rbx_mesh::mesh::{Vertex2, Vertex2Truncated};
-use strafesnet_common::{integer::vec3,model::{self, ColorId, IndexedVertex, NormalId, PolygonGroup, PolygonList, PositionId, TextureCoordinateId, VertexId}};
+use rbx_mesh::mesh::{Vertex2,Vertex2Truncated};
+use strafesnet_common::{integer::vec3,model::{self,ColorId,IndexedVertex,NormalId,PolygonGroup,PolygonList,PositionId,RenderConfigId,TextureCoordinateId,VertexId}};
 
 #[allow(dead_code)]
 #[derive(Debug)]
@@ -205,7 +205,13 @@ pub fn convert(roblox_mesh_bytes:crate::data::RobloxMeshBytes)->Result<model::Me
 		unique_vertices,
 		polygon_groups,
 		//these should probably be moved to the model...
-		graphics_groups:Vec::new(),
+		//but what if models want to use the same texture
+		graphics_groups:vec![model::IndexedGraphicsGroup{
+			render:RenderConfigId::new(0),
+			//the lowest lod is highest quality
+			groups:vec![model::PolygonGroupId::new(0)]
+		}],
+		//disable physics
 		physics_groups:Vec::new(),
 	})
 }
diff --git a/lib/rbx_loader/src/rbx.rs b/lib/rbx_loader/src/rbx.rs
index 643cf3d..6afcdbf 100644
--- a/lib/rbx_loader/src/rbx.rs
+++ b/lib/rbx_loader/src/rbx.rs
@@ -406,6 +406,7 @@ enum RobloxBasePartDescription{
 enum Shape{
 	Primitive(primitives::Primitives),
 	MeshPart,
+	PhysicsData,
 }
 enum MeshAvailability{
 	Immediate,
@@ -446,6 +447,7 @@ where
 	let mut primitive_models_deferred_attributes=Vec::new();
 	let mut primitive_meshes=Vec::new();
 	let mut mesh_id_from_description=HashMap::new();
+	let mut mesh_id_from_physics_data=HashMap::<&[u8],_>::new();
 
 	//just going to leave it like this for now instead of reworking the data structures for this whole thing
 	let textureless_render_group=acquire_render_config_id(None);
@@ -471,7 +473,7 @@ where
 					object.properties.get("CanCollide"),
 				)
 			{
-				let model_transform=planar64_affine3_from_roblox(cf,size);
+				let mut model_transform=planar64_affine3_from_roblox(cf,size);
 
 				if model_transform.matrix3.det().is_zero(){
 					let mut parent_ref=object.parent();
@@ -503,6 +505,7 @@ where
 					"WedgePart"=>Shape::Primitive(primitives::Primitives::Wedge),
 					"CornerWedgePart"=>Shape::Primitive(primitives::Primitives::CornerWedge),
 					"MeshPart"=>Shape::MeshPart,
+					"UnionOperation"=>Shape::PhysicsData,
 					_=>{
 						println!("Unsupported BasePart ClassName={}; defaulting to cube",object.class);
 						Shape::Primitive(primitives::Primitives::Cube)
@@ -697,6 +700,41 @@ where
 					}else{
 						panic!("Mesh has no Mesh or Texture");
 					},
+					Shape::PhysicsData=>{
+						//The union mesh is sized already
+						model_transform=planar64_affine3_from_roblox(cf,&rbx_dom_weak::types::Vector3{x:2.0,y:2.0,z:2.0});
+						if let Some(rbx_dom_weak::types::Variant::BinaryString(data))=object.properties.get("PhysicsData"){
+							let physics_data=data.as_ref();
+							let mesh_id=if let Some(&mesh_id)=mesh_id_from_physics_data.get(physics_data){
+								mesh_id
+							}else{
+								match crate::union::convert(physics_data){
+									Ok(mesh)=>{
+										let mesh_id=model::MeshId::new(primitive_meshes.len() as u32);
+										primitive_meshes.push(mesh);
+										mesh_id_from_physics_data.insert(physics_data,mesh_id);
+										mesh_id
+									},
+									Err(e)=>{
+										model_transform=planar64_affine3_from_roblox(cf,size);
+										if !matches!(e,crate::union::Error::Block){
+											println!("Union mesh decode error {e:?}");
+										}
+										*mesh_id_from_description.entry(RobloxBasePartDescription::Part(RobloxPartDescription::default()))
+										.or_insert_with(||{
+											let mesh_id=model::MeshId::new(primitive_meshes.len() as u32);
+											let mesh=primitives::unit_cube(textureless_render_group);
+											primitive_meshes.push(mesh);
+											mesh_id
+										})
+									},
+								}
+							};
+							(MeshAvailability::Immediate,mesh_id)
+						}else{
+							panic!("Mesh has no Mesh or Texture");
+						}
+					},
 				};
 				let model_deferred_attributes=ModelDeferredAttributes{
 					mesh:mesh_id,
@@ -776,12 +814,10 @@ impl PartialMap1{
 				.entry(render).or_insert_with(||{
 					let mesh_id=model::MeshId::new(self.primitive_meshes.len() as u32);
 					let mut mesh_clone=mesh_with_aabb.mesh.clone();
-					//add a render group lool
-					mesh_clone.graphics_groups.push(model::IndexedGraphicsGroup{
-						render,
-						//the lowest lod is highest quality
-						groups:vec![model::PolygonGroupId::new(0)]
-					});
+					//set the render group lool
+					if let Some(graphics_group)=mesh_clone.graphics_groups.first_mut(){
+						graphics_group.render=render;
+					}
 					self.primitive_meshes.push(mesh_clone);
 					mesh_id
 				}),
diff --git a/lib/rbx_loader/src/union.rs b/lib/rbx_loader/src/union.rs
new file mode 100644
index 0000000..b4b8131
--- /dev/null
+++ b/lib/rbx_loader/src/union.rs
@@ -0,0 +1,126 @@
+use std::collections::HashMap;
+
+use strafesnet_common::model::{self, ColorId, IndexedVertex, NormalId, PolygonGroup, PolygonGroupId, PolygonList, PositionId, RenderConfigId, TextureCoordinateId, VertexId};
+use strafesnet_common::integer::vec3;
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub enum Error{
+	Block,
+	MissingVertexId(u32),
+	Planar64Vec3(strafesnet_common::integer::Planar64TryFromFloatError),
+	RobloxPhysicsData(rbx_mesh::physics_data::Error),
+}
+impl std::fmt::Display for Error{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,"{self:?}")
+	}
+}
+
+impl std::error::Error for Error{}
+pub fn convert(roblox_physics_data:&[u8])->Result<model::Mesh,Error>{
+	if let b""=roblox_physics_data{
+		return Err(Error::Block);
+	}
+	let mut cursor=std::io::Cursor::new(roblox_physics_data);
+	let physics_data:rbx_mesh::physics_data::PhysicsData=rbx_mesh::read_physics_data(&mut cursor).map_err(Error::RobloxPhysicsData)?;
+	assert_eq!(cursor.position(),cursor.into_inner().len() as u64);
+	let meshes=match physics_data{
+		rbx_mesh::physics_data::PhysicsData::CSGK(_)
+		|rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::Block)
+		=>return Err(Error::Block),
+		rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::Meshes3(meshes))
+		|rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::Meshes5(meshes))
+		=>meshes.meshes,
+		rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::PhysicsInfoMesh(pim))
+		=>vec![pim.mesh],
+	};
+	let mut unique_pos=Vec::new();
+	let mut pos_id_from=HashMap::new();
+	let mut unique_tex=Vec::new();
+	let mut tex_id_from=HashMap::new();
+	let mut unique_normal=Vec::new();
+	let mut normal_id_from=HashMap::new();
+	let mut unique_color=Vec::new();
+	let mut color_id_from=HashMap::new();
+	let mut unique_vertices=Vec::new();
+	let mut vertex_id_from=HashMap::new();
+	let mut acquire_pos_id=|pos|{
+		let p=vec3::try_from_f32_array(pos).map_err(Error::Planar64Vec3)?;
+		Ok(PositionId::new(*pos_id_from.entry(p).or_insert_with(||{
+			let pos_id=unique_pos.len();
+			unique_pos.push(p);
+			pos_id
+		}) as u32))
+	};
+	let mut acquire_tex_id=|tex|{
+		let h=bytemuck::cast::<[f32;2],[u32;2]>(tex);
+		TextureCoordinateId::new(*tex_id_from.entry(h).or_insert_with(||{
+			let tex_id=unique_tex.len();
+			unique_tex.push(glam::Vec2::from_array(tex));
+			tex_id
+		}) as u32)
+	};
+	let mut acquire_normal_id=|normal|{
+		let n=vec3::try_from_f32_array(normal).map_err(Error::Planar64Vec3)?;
+		Ok(NormalId::new(*normal_id_from.entry(n).or_insert_with(||{
+			let normal_id=unique_normal.len();
+			unique_normal.push(n);
+			normal_id
+		}) as u32))
+	};
+	let mut acquire_color_id=|color|{
+		let h=bytemuck::cast::<[f32;4],[u32;4]>(color);
+		ColorId::new(*color_id_from.entry(h).or_insert_with(||{
+			let color_id=unique_color.len();
+			unique_color.push(glam::Vec4::from_array(color));
+			color_id
+		}) as u32)
+	};
+	let mut acquire_vertex_id=|vertex:IndexedVertex|{
+		VertexId::new(*vertex_id_from.entry(vertex.clone()).or_insert_with(||{
+			let vertex_id=unique_vertices.len();
+			unique_vertices.push(vertex);
+			vertex_id
+		}) as u32)
+	};
+	let color=acquire_color_id([1.0f32;4]);
+	let tex=acquire_tex_id([0.0f32;2]);
+	let polygon_groups:Vec<PolygonGroup>=meshes.into_iter().map(|mesh|{
+		Ok(PolygonGroup::PolygonList(PolygonList::new(mesh.faces.into_iter().map(|[vertex_id0,vertex_id1,vertex_id2]|{
+			let v0=mesh.vertices.get(vertex_id0.0 as usize).ok_or(Error::MissingVertexId(vertex_id0.0))?;
+			let v1=mesh.vertices.get(vertex_id1.0 as usize).ok_or(Error::MissingVertexId(vertex_id1.0))?;
+			let v2=mesh.vertices.get(vertex_id2.0 as usize).ok_or(Error::MissingVertexId(vertex_id2.0))?;
+			let vertex_norm=(glam::Vec3::from_slice(v1)-glam::Vec3::from_slice(v0))
+			          .cross(glam::Vec3::from_slice(v2)-glam::Vec3::from_slice(v0)).to_array();
+			let mut ingest_vertex_id=|&vertex_pos:&[f32;3]|Ok(acquire_vertex_id(IndexedVertex{
+				pos:acquire_pos_id(vertex_pos)?,
+				tex,
+				normal:acquire_normal_id(vertex_norm)?,
+				color,
+			}));
+			Ok(vec![
+				ingest_vertex_id(v0)?,
+				ingest_vertex_id(v1)?,
+				ingest_vertex_id(v2)?,
+			])
+		}).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{
+		groups:vec![PolygonGroupId::new(id as u32)]
+	}).collect();
+	Ok(model::Mesh{
+		unique_pos,
+		unique_normal,
+		unique_tex,
+		unique_color,
+		unique_vertices,
+		polygon_groups,
+		graphics_groups,
+		physics_groups,
+	})
+}