Skip to main content

physics_toy_sandbox/
lib.rs

1//! # Physics Toy Sandbox
2//!
3//! A remixable physics playground - Rube Goldberg machine builder for Jugar.
4//!
5//! This crate implements the Physics Toy Sandbox specification with Toyota Way principles:
6//!
7//! - **Kaizen**: Every remix is continuous improvement
8//! - **Poka-Yoke**: Type-safe material properties (`NonZeroU32` for density)
9//! - **Jidoka**: Engine versioning ensures replay compatibility
10//! - **Mieruka**: Complexity Thermometer provides visual feedback
11//! - **Muda Elimination**: No scalar fallback (SIMD support >99%)
12//!
13//! ## Architecture
14//!
15//! ```text
16//! ┌─────────────────────────────────────────────────────────────────┐
17//! │                    PHYSICS TOY SANDBOX                           │
18//! ├─────────────────────────────────────────────────────────────────┤
19//! │  Contraption ──► RemixGraph ──► Storage                         │
20//! │       │                                                          │
21//! │       ├── MaterialProperties (Poka-Yoke: NonZeroU32 density)    │
22//! │       ├── PhysicsConfig (versioned)                             │
23//! │       └── SerializedEntity[]                                    │
24//! │                                                                  │
25//! │  ComplexityThermometer (Mieruka) ──► UI Feedback                │
26//! └─────────────────────────────────────────────────────────────────┘
27//! ```
28
29#![forbid(unsafe_code)]
30#![warn(missing_docs)]
31#![warn(clippy::pedantic)]
32#![allow(clippy::module_name_repetitions)]
33
34use glam::Vec2;
35use serde::{Deserialize, Serialize};
36use thiserror::Error;
37use uuid::Uuid;
38
39pub mod contraption;
40pub mod material;
41pub mod remix;
42pub mod thermometer;
43
44pub use contraption::*;
45pub use material::*;
46pub use remix::*;
47pub use thermometer::*;
48
49/// Content-addressed ID for contraptions (SHA-256 based)
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct ContraptionId(pub Uuid);
52
53impl ContraptionId {
54    /// Create a new random contraption ID
55    #[must_use]
56    pub fn new() -> Self {
57        Self(Uuid::new_v4())
58    }
59
60    /// Create from existing UUID
61    #[must_use]
62    pub const fn from_uuid(uuid: Uuid) -> Self {
63        Self(uuid)
64    }
65}
66
67impl Default for ContraptionId {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl core::fmt::Display for ContraptionId {
74    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75        write!(f, "{}", self.0)
76    }
77}
78
79/// Errors that can occur in physics-toy-sandbox
80#[derive(Error, Debug, Clone, PartialEq, Eq)]
81pub enum SandboxError {
82    /// Contraption exceeds object limit
83    #[error("Contraption exceeds object limit: {count} > {limit}")]
84    ObjectLimitExceeded {
85        /// Current object count
86        count: usize,
87        /// Maximum allowed
88        limit: usize,
89    },
90
91    /// Invalid material properties
92    #[error("Invalid material: {reason}")]
93    InvalidMaterial {
94        /// Reason for invalidity
95        reason: String,
96    },
97
98    /// Serialization error
99    #[error("Serialization failed: {0}")]
100    SerializationError(String),
101
102    /// Deserialization error (malformed data)
103    #[error("Deserialization failed: invalid or corrupt data")]
104    DeserializationError,
105
106    /// Engine version mismatch
107    #[error("Engine version mismatch: contraption requires {required}, current is {current}")]
108    VersionMismatch {
109        /// Required engine version
110        required: String,
111        /// Current engine version
112        current: String,
113    },
114
115    /// Contraption not found
116    #[error("Contraption not found: {0}")]
117    NotFound(ContraptionId),
118}
119
120/// Result type for sandbox operations
121pub type Result<T> = core::result::Result<T, SandboxError>;
122
123/// Maximum objects per contraption (performance budget)
124pub const MAX_OBJECTS_PER_CONTRAPTION: usize = 500;
125
126/// Current engine version for compatibility tracking (Jidoka)
127pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
128
129/// Physics backend selection (Muda: no scalar fallback)
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
131pub enum PhysicsBackend {
132    /// WebGPU compute shaders (10,000+ bodies)
133    #[default]
134    WebGpu,
135    /// WASM SIMD 128-bit (1,000+ bodies)
136    WasmSimd,
137}
138
139/// 2D Transform for sandbox objects
140#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
141pub struct Transform2D {
142    /// Position in world space
143    pub position: Vec2,
144    /// Rotation in radians
145    pub rotation: f32,
146    /// Scale (uniform or non-uniform)
147    pub scale: Vec2,
148}
149
150impl Default for Transform2D {
151    fn default() -> Self {
152        Self {
153            position: Vec2::ZERO,
154            rotation: 0.0,
155            scale: Vec2::ONE,
156        }
157    }
158}
159
160/// Object types available in the sandbox
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
162pub enum ObjectType {
163    /// Dynamic ball - rolls and bounces
164    Ball,
165    /// Dynamic domino - falls and triggers chain reactions
166    Domino,
167    /// Static ramp - redirects objects
168    Ramp,
169    /// Hinged lever - pivots around fulcrum
170    Lever,
171    /// Constraint pulley - transfers force
172    Pulley,
173    /// Constraint spring - stores elastic energy
174    Spring,
175    /// Force field fan - applies directional force
176    Fan,
177    /// Force field magnet - attracts/repels
178    Magnet,
179    /// Trigger bucket - detects objects (win condition)
180    Bucket,
181    /// Trigger sensor - detects proximity
182    Sensor,
183}
184
185impl ObjectType {
186    /// Is this object type dynamic (affected by physics)?
187    #[must_use]
188    pub const fn is_dynamic(&self) -> bool {
189        matches!(self, Self::Ball | Self::Domino)
190    }
191
192    /// Is this object type a trigger (detects but doesn't collide)?
193    #[must_use]
194    pub const fn is_trigger(&self) -> bool {
195        matches!(self, Self::Bucket | Self::Sensor)
196    }
197
198    /// Is this object type a constraint (connects other objects)?
199    #[must_use]
200    pub const fn is_constraint(&self) -> bool {
201        matches!(self, Self::Pulley | Self::Spring)
202    }
203}
204
205/// Difficulty rating for contraptions
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
207pub enum Difficulty {
208    /// Simple contraption (< 10 objects)
209    Easy,
210    /// Moderate complexity (10-50 objects)
211    #[default]
212    Medium,
213    /// Complex contraption (50-200 objects)
214    Hard,
215    /// Expert level (200+ objects)
216    Expert,
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)]
221mod tests {
222    use super::*;
223
224    // =========================================================================
225    // EXTREME TDD: Tests written FIRST per specification
226    // =========================================================================
227
228    mod contraption_id_tests {
229        use super::*;
230
231        #[test]
232        fn test_contraption_id_uniqueness() {
233            let id1 = ContraptionId::new();
234            let id2 = ContraptionId::new();
235            assert_ne!(id1, id2, "Each ID should be unique");
236        }
237
238        #[test]
239        fn test_contraption_id_display() {
240            let id = ContraptionId::new();
241            let display = id.to_string();
242            assert!(!display.is_empty());
243            assert!(display.contains('-'), "UUID format should contain hyphens");
244        }
245
246        #[test]
247        fn test_contraption_id_from_uuid() {
248            let uuid = Uuid::new_v4();
249            let id = ContraptionId::from_uuid(uuid);
250            assert_eq!(id.0, uuid);
251        }
252    }
253
254    mod object_type_tests {
255        use super::*;
256
257        #[test]
258        fn test_ball_is_dynamic() {
259            assert!(ObjectType::Ball.is_dynamic());
260        }
261
262        #[test]
263        fn test_domino_is_dynamic() {
264            assert!(ObjectType::Domino.is_dynamic());
265        }
266
267        #[test]
268        fn test_ramp_is_not_dynamic() {
269            assert!(!ObjectType::Ramp.is_dynamic());
270        }
271
272        #[test]
273        fn test_bucket_is_trigger() {
274            assert!(ObjectType::Bucket.is_trigger());
275        }
276
277        #[test]
278        fn test_sensor_is_trigger() {
279            assert!(ObjectType::Sensor.is_trigger());
280        }
281
282        #[test]
283        fn test_ball_is_not_trigger() {
284            assert!(!ObjectType::Ball.is_trigger());
285        }
286
287        #[test]
288        fn test_spring_is_constraint() {
289            assert!(ObjectType::Spring.is_constraint());
290        }
291
292        #[test]
293        fn test_pulley_is_constraint() {
294            assert!(ObjectType::Pulley.is_constraint());
295        }
296
297        #[test]
298        fn test_ball_is_not_constraint() {
299            assert!(!ObjectType::Ball.is_constraint());
300        }
301    }
302
303    mod transform_tests {
304        use super::*;
305
306        #[test]
307        fn test_transform_default() {
308            let t = Transform2D::default();
309            assert_eq!(t.position, Vec2::ZERO);
310            assert!((t.rotation - 0.0).abs() < f32::EPSILON);
311            assert_eq!(t.scale, Vec2::ONE);
312        }
313
314        #[test]
315        fn test_transform_serialization() {
316            let t = Transform2D {
317                position: Vec2::new(100.0, 200.0),
318                rotation: 1.5,
319                scale: Vec2::new(2.0, 3.0),
320            };
321            let json = serde_json::to_string(&t).unwrap();
322            let restored: Transform2D = serde_json::from_str(&json).unwrap();
323            assert_eq!(t, restored);
324        }
325    }
326
327    mod physics_backend_tests {
328        use super::*;
329
330        #[test]
331        fn test_default_backend_is_webgpu() {
332            assert_eq!(PhysicsBackend::default(), PhysicsBackend::WebGpu);
333        }
334
335        #[test]
336        fn test_backend_serialization() {
337            let backend = PhysicsBackend::WasmSimd;
338            let json = serde_json::to_string(&backend).unwrap();
339            let restored: PhysicsBackend = serde_json::from_str(&json).unwrap();
340            assert_eq!(backend, restored);
341        }
342    }
343
344    mod error_tests {
345        use super::*;
346
347        #[test]
348        fn test_object_limit_error_display() {
349            let err = SandboxError::ObjectLimitExceeded {
350                count: 600,
351                limit: 500,
352            };
353            let msg = err.to_string();
354            assert!(msg.contains("600"));
355            assert!(msg.contains("500"));
356        }
357
358        #[test]
359        fn test_version_mismatch_error() {
360            let err = SandboxError::VersionMismatch {
361                required: "1.0.0".to_string(),
362                current: "0.1.0".to_string(),
363            };
364            let msg = err.to_string();
365            assert!(msg.contains("1.0.0"));
366            assert!(msg.contains("0.1.0"));
367        }
368
369        #[test]
370        fn test_deserialization_error() {
371            let err = SandboxError::DeserializationError;
372            let msg = err.to_string();
373            assert!(msg.contains("invalid") || msg.contains("corrupt"));
374        }
375    }
376
377    mod difficulty_tests {
378        use super::*;
379
380        #[test]
381        fn test_default_difficulty_is_medium() {
382            assert_eq!(Difficulty::default(), Difficulty::Medium);
383        }
384    }
385}