From ab3f056dbbb7d92ddc76b7b26dae0a52a4d28d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Thu, 24 Sep 2026 14:25:44 +0200 Subject: [PATCH] primitive meshes --- docs/DRAFT.md | 121 +++++-- docs/ROADMAP.md | 244 ++++--------- docs/user/README.md | 26 ++ docs/user/mesh.md | 108 ++++++ lib/Cargo.toml | 16 + lib/examples/cube.rs | 2 +- lib/examples/demo.rs | 3 +- lib/examples/import.rs | 66 ++++ lib/examples/shadow_test.rs | 4 +- lib/examples/spot_test.rs | 2 +- lib/src/{math => core}/frustum.rs | 0 lib/src/{math => core}/geometry.rs | 20 +- lib/src/{math => core}/lod.rs | 0 lib/src/core/mod.rs | 8 + lib/src/core/renderer.rs | 4 +- lib/src/{math => core}/transform.rs | 0 lib/src/lib.rs | 20 +- lib/src/math/mod.rs | 31 -- lib/src/math/primitives.rs | 534 ---------------------------- lib/src/mesh/import/gltf.rs | 20 ++ lib/src/mesh/import/mod.rs | 31 ++ lib/src/mesh/import/obj.rs | 311 ++++++++++++++++ lib/src/mesh/mod.rs | 58 +++ lib/src/mesh/primitives/cone.rs | 89 +++++ lib/src/mesh/primitives/cube.rs | 97 +++++ lib/src/mesh/primitives/cylinder.rs | 94 +++++ lib/src/mesh/primitives/mod.rs | 33 ++ lib/src/mesh/primitives/plane.rs | 74 ++++ lib/src/mesh/primitives/sphere.rs | 176 +++++++++ lib/src/mesh/primitives/torus.rs | 79 ++++ lib/src/prelude.rs | 46 +++ lib/src/resources/mesh.rs | 4 +- lib/src/resources/mod.rs | 2 +- lib/src/resources/uniform.rs | 12 +- lib/src/scene/entity.rs | 2 +- lib/src/scene/scene.rs | 2 +- 36 files changed, 1531 insertions(+), 808 deletions(-) create mode 100644 docs/user/mesh.md create mode 100644 lib/examples/import.rs rename lib/src/{math => core}/frustum.rs (100%) rename lib/src/{math => core}/geometry.rs (99%) rename lib/src/{math => core}/lod.rs (100%) rename lib/src/{math => core}/transform.rs (100%) delete mode 100644 lib/src/math/mod.rs delete mode 100644 lib/src/math/primitives.rs create mode 100644 lib/src/mesh/import/gltf.rs create mode 100644 lib/src/mesh/import/mod.rs create mode 100644 lib/src/mesh/import/obj.rs create mode 100644 lib/src/mesh/mod.rs create mode 100644 lib/src/mesh/primitives/cone.rs create mode 100644 lib/src/mesh/primitives/cube.rs create mode 100644 lib/src/mesh/primitives/cylinder.rs create mode 100644 lib/src/mesh/primitives/mod.rs create mode 100644 lib/src/mesh/primitives/plane.rs create mode 100644 lib/src/mesh/primitives/sphere.rs create mode 100644 lib/src/mesh/primitives/torus.rs create mode 100644 lib/src/prelude.rs diff --git a/docs/DRAFT.md b/docs/DRAFT.md index f202fd2..7a9cf86 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,42 +1,99 @@ -# DRAFT — Étape 20 : HDR + Tone Mapping ✅ +# Étape 21 — Module `mesh` : primitives optionnelles + import -> **STATUT : TERMINÉ** — implémenté et testé. -> Ce document sera remplacé par le prochain draft. +**Statut : ✅ TERMINÉE** -## Récapitulatif +## Résumé -- [x] **20.1** — Shader TM (`tonemap.wgsl`) : fullscreen triangle + 2 curves (ACES/Reinhard) + validation naga ✅ -- [x] **20.2** — `ToneMapper` enum (`core/hdr.rs`) : dispatch compile-time ✅ -- [x] **20.3** — `AppBuilder::with_hdr(ToneMapper)` + plomberie App → AppRunner → Renderer ✅ -- [x] **20.4** — Allocation HDR (`Rgba16Float` offscreen) dans `Renderer::new` ✅ -- [x] **20.5** — Main pass conditionnel (cible HDR vs surface) ✅ -- [x] **20.6** — Passe TM (fullscreen triangle → surface sRGB) ✅ -- [x] **20.7** — Resize : recreation texture HDR + bind group ✅ -- [x] **20.8** — Démo HDR + documentation (`docs/user/hdr.md`) ✅ +Restructuration du module de géométrie : +- `math/` supprimé — types (`Geometry`, `Transform`, `BBox`, `Frustum`, LOD) déplacés vers `core/` +- `primitives.rs` (monolith) → `mesh/primitives/` (6 fichiers, un par famille) +- Nouveau module `wsg::mesh` : point d'entrée unique pour les sources de géométrie +- Features par primitive (`prim-cube`, `prim-sphere`, …) — zéro coût si désactivées +- Parser OBJ intégré (zéro dep externe), wrapper glTF en stub +- `prelude.rs` pour un glob import confortable +- Re-exports top-level : `Geometry`, `Transform`, `BBox` -## Fichiers modifiés/créés +## Structure finale -| Fichier | Action | -|---------|--------| -| `lib/src/shaders/tonemap.wgsl` | **Nouveau** — fullscreen triangle + fs_aces + fs_reinhard | -| `lib/src/core/hdr.rs` | **Nouveau** — `ToneMapper` enum | -| `lib/src/core/mod.rs` | + `pub mod hdr` + re-export | -| `lib/src/core/renderer.rs` | + `HdrPipeline` struct, + HDR alloc, + TM pass, + resize, + helpers | -| `lib/src/utils/conf.rs` | + `TONEMAP_SHADER` constant | -| `lib/src/app.rs` | + `with_hdr()`, + `hdr` field plomberie | -| `lib/src/lib.rs` | + `pub use ToneMapper` | -| `lib/examples/demo.rs` | + `.with_hdr(ToneMapper::Aces)` | -| `lib/examples/manual.rs` | + `None` param (backward compat) | -| `lib/tests/wgsl_validate.rs` | + test tonemap | -| `docs/user/hdr.md` | **Nouveau** — doc utilisateur | -| `docs/user/README.md` | + lien HDR | +``` +lib/src/ +├── lib.rs # + pub mod mesh, pub mod prelude, re-exports Geometry/Transform/BBox +├── prelude.rs # glob re-exports (types quotidiens) +├── core/ +│ ├── mod.rs # + geometry, transform, frustum, lod +│ ├── geometry.rs # ← déplacé de math/ +│ ├── transform.rs # ← déplacé de math/ +│ ├── frustum.rs # ← déplacé de math/ +│ ├── lod.rs # ← déplacé de math/ +│ ├── renderer.rs +│ ├── shadow.rs +│ ├── hdr.rs +│ ├── context.rs +│ ├── frame.rs +│ └── input.rs +├── mesh/ +│ ├── mod.rs # re-exports flat (cube, plane, sphere, …, load_obj, …) +│ ├── primitives/ +│ │ ├── mod.rs +│ │ ├── cube.rs +│ │ ├── plane.rs +│ │ ├── sphere.rs # uv_sphere + icosphere +│ │ ├── cylinder.rs +│ │ ├── cone.rs +│ │ └── torus.rs +│ └── import/ +│ ├── mod.rs # MeshImportError +│ ├── obj.rs # parser OBJ (zéro dep) +│ └── gltf.rs # stub (wrapper gltf crate à implémenter) +├── app.rs +├── handler.rs +├── pipeline/ +├── resources/ +├── scene/ +└── utils/ +``` + +## Features (Cargo.toml) + +| Feature | Default | Fournit | +|---------|---------|---------| +| `prim-cube` | ✅ (via all-prims) | `cube(size)` | +| `prim-plane` | ✅ | `plane(w, d, sx, sz)` | +| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` | +| `prim-cylinder` | ✅ | `cylinder(…)` | +| `prim-cone` | ✅ | `cone(…)` | +| `prim-torus` | ✅ | `torus(…)` | +| `all-prims` | ✅ (default) | les 6 ci-dessus | +| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` | +| `import-gltf` | ⬜ | `load_gltf(path)` (stub) | + +## Décisions + +| # | Décision | +|---|----------| +| D1 | Un seul crate `wsg-lib` — pas de crate séparée | +| D2 | Feature par famille de primitives | +| D3 | Feature par format d'import | +| D4 | Pas de trait `MeshSource` — fonctions qui retournent `Geometry` | +| D5 | `Geometry::new()` / `Scene::add_mesh()` restent en core | +| D6 | Module `wsg::mesh` au même niveau que `core`, `app` | +| D7 | `primitives/` un fichier par famille | +| D8 | `import/` un fichier par format | +| D9 | Import retourne `Result<_, MeshImportError>` | +| D10 | `default = ["all-prims"]` | +| D11 | `all-prims` = les 6 primitives | +| D12 | `math` disparaît — types re-exportés par `core` / top-level | ## Tests -- 99 unit tests ✅ -- 4 WGSL validation (dont `tonemap_shader_is_valid_wgsl`) ✅ -- 3 doctests ✅ +- 107 unit tests (dont 7 tests OBJ parser) +- 4 WGSL validation +- 5 doctests +- **Total : 116 tests, 0 failures** -## Prochaine étape +## Build vérifié -Phase 4 complète (4.1 + 4.2 + 4.3 + HDR/TM). Le ROADMAP peut être mis à jour. +- `cargo check` (default = all-prims) ✅ +- `cargo check --no-default-features --features "prim-cube"` ✅ +- `cargo check --features "import-obj,import-gltf"` ✅ +- `cargo check --examples --features "import-obj"` ✅ diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bc71384..a6a27ab 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,202 +1,90 @@ ---- -type: Roadmap -title: WSG Engine Development Roadmap -description: Development roadmap for the WSG engine from prototype to full-featured 3D rendering engine -tags: [roadmap, development, planning, wsg-lib, 3d-rendering] -status: stable -generated: { by: human:jerome, at: 2026-07-31T00:00:00Z } ---- +# ROADMAP — WSG -# Roadmap WSG — Prototype → Moteur Complet +**Vision** : une lib Rust de dessin 3D simple, fondée sur `wgpu`, où l'API utilisateur est +déclarative (graph scène + traits) et où le rendu est **100 % GPU-driven** (indirect draws). -> Basé sur l'architecture existante (ARCHI_APP, ARCHI_ARENES, ARCHI_CPU_GPU, ARCHI_RENDU). -> Objectif : prototype fonctionnel d'abord, enrichissement progressif ensuite. -> -> **Point de départ (état réel au 2026-09-16 — la source de vérité est README.md).** -> Les fondations suivantes existent et fonctionnent déjà ; cette roadmap décrit la **trajectoire à -> venir** à partir de cet état (elle reprend les étapes 1-4 du README avant la montée GPU-driven) : -> - Workflow manuel (`Context` + `Renderer` + `PipelineCache`) : ✅ fonctionnel (exemple `manual`). -> - Façade `App` / `AppBuilder` / `AppHandler` : ✅ **Scene auto-render** (2026-09-16) — la vue de frame -> est exposée (`Frame::view()`), `render()` dessine la scène en une passe groupée -> (`App::render_scene`) et la présentation est automatique dans `App::run` (exemple `simple`). -> - `Scene` avec identifiants **String** (décision prise — voir tableau Notes de Décision) : 🚧 enregistrement seul. -> - `Camera` / `Transform` et `glam` : types et mathématiques présents (`math/`, `resources/camera.rs`), -> initialement non branchés au pipeline — **désormais branchés** (caméra active + matrices monde écrites -> chaque frame, Étape 4.3, 2026-09-16 ; voir §1.1/1.5 ci-dessous). +Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT détaillé +(`DRAFT.md`, remplacé à chaque étape) et sa doc livrée (`docs/tech/`, `docs/user/`). -> **Étape suivante (résolue 2026-09-17).** « 3D + éclairage Phong » (ROADMAP 1.3 + 1.5) est **atteinte** : -> le rendu automatique n'est plus plat. L'infrastructure (Étapes 3+4, 2026-09-16) — `standard_shader.wgsl` -> Phong (matrice `projection * view * world` + lumière directionnelle), uniform buffers branchés -> (frame : view/proj/cam_pos + lumière ; par mesh : `world` dérivé du `Transform`), `Renderer` écrivant -> chaque frame la caméra active et la matrice monde de chaque entité — est **branchée** sur l'exemple -> `cube` (Étape 5, 2026-09-17) : un cube unitaire éclairé qui tourne à l'écran via `App::render_scene`. -> Le shader `basic` est supprimé : le 2D plat devient la variante **unlit** de `standard` -> (`Renderer::set_unlit(true)`). Objectif MVP **atteint**. +> **Légende** : ✅ fait · 🔶 partiel · ⬜ à faire · ❌ abandonné +> **Principe** : chaque étape est **additive et opt-in** — non-régression structurelle garantie +> (tout reste désactivable, les chemins existants ne changent pas). --- -## Phase 1️⃣ — Prototype MVP : Un Mesh 3D éclairé à l'écran +## Phase 1 — Fondations ✅ -**Objectif** : Afficher un cube (ou autre mesh) 3D avec un éclairage Phong basique. +| # | Item | Statut | +|---|------|:------:| +| 1.1 | Contexte GPU (Instance, Surface, Adapter, Device, Queue) + boucle winit | ✅ | +| 1.2 | Buffers & Pipeline (vertex buffer, pipeline compilé, fullscreen) | ✅ | +| 1.3 | Geometry (struct `Geometry`, buffers GPU, topologie, `PrimitiveTopology`) | ✅ | -### 1.1 Dépendances & Mathématiques -- [x] `glam = "0.33"` ajouté (`lib/Cargo.toml`) — déjà présent, utilisé par `math/transform.rs` et `resources/camera.rs` -- [x] `slotmap` **retiré** — décision prise : **String IDs pour le MVP** ; slotmap reporté à l'étape "handles typés" (voir Notes de Décision) -- [x] Module `math/` / `transform.rs`: - - [x] Struct `Transform { translation: Vec3, rotation: Quat, scale: Vec3 }` - - [x] Méthode `to_matrix() -> Mat4` pour calculer la matrice locale - - [x] Struct `Camera { position: Vec3, target: Vec3, up: Vec3 }` : resources/camera.rs — enrichi en Étape 4.3 (fov/near/far + `with_perspective`) - - [x] Fonctions `view_matrix()` et `projection_matrix(fov, aspect, near, far)` (Étape 4.3 : `projection_matrix(aspect)` utilise fov/near/far stockés) +## Phase 2 — Scène & Transforms ✅ -### 1.2 Geometry & Mesh -- [x] Créer struct `Geometry` (math/geometry.rs) — **fait** : - - [x] `positions: Vec<[f32; 3]>` (obligatoire) - - [x] `indices: Option>` (optionnel) - - [x] `normals: Option>` (pour Phong) — plus `uvs: Option>` - - [x] `colors: Option>` — **fait (Étape 8, 8.1, 2026-09-18)** : décidé en DRAFT Étape 8 (D1) ; - le shader lit la couleur unlit, elle est donc portée dans `Geometry`. Conversion - `Geometry -> Vec` via `Geometry::to_vertices()` (D6) pour l'upload. -- [x] Refactorer `Mesh` pour contenir — **fait (Étape 8, 8.3, 2026-09-18)** : - - [x] `geometry: Arc` (rétention CPU, D5) + accesseur `geometry()` - - [x] `vertex_buffer: wgpu::Buffer` - - [x] `index_buffer: Option` - - Construction via `Mesh::from_geometry(device, Arc, material)` (D4) ; les anciennes - voies `Mesh::new`/`with_material` (`&[Vertex]`) sont supprimées. - - `Scene::create_mesh(id, Geometry, Option<&str>)` (8.4) ; exemples cube/simple/manual réécrits - sur `Geometry` (8.5). -- [x] Ajouter un mesh de test (cube unitaire) en exemple — **fait** (helper `cube_geometry` dans l'exemple `cube`, Étape 5, 2026-09-17) +| # | Item | Statut | +|---|------|:------:| +| 2.1 | Primitives procédurales (`cube`, `plane`, `sphere`, `cylinder`, `cone`, `torus`) | ✅ | +| 2.2 | Transforms (struct `Transform`, composition translation × rotation × scale) | ✅ | +| 2.3 | Entités & scène (struct `Entity`, `Scene`, `TransformStore`, graph entité→mesh) | ✅ | +| 2.4 | Camera (struct `Camera`, matrices view + perspective, `CameraController` orbital) | ✅ | -> **État (2026-09-18)** : `Mesh` porte son matériau (`mesh.material: Option>`, Étape 7) et -> une source de vérité CPU partagée (`geometry: Arc`, Étape 8). `Mesh` ne porte **pas** de -> `transform` : un même mesh est partagé par plusieurs entités ; le `transform` vit sur `Entity`. +## Phase 3 — GPU-driven (cœur de la vision) ✅ -### 1.3 Shader Phong Minimal -- [x] Créer `standard_shader.wgsl` (Étape 2, 2026-09-16) : - - [x] Vertex shader : projection * view * world * position - - [x] Fragment shader : éclairage directionnel (+ hémisphérique) - - [x] Uniforms : `view`, `proj`, `cam_pos`, `light_dir`, `light_color`, `options` -- [x] Mettre à jour `Material` / pipeline pour supporter les uniforms du shader Phong (bind group layouts frame+object, Étape 3) — **désormais branché** sur l'exemple `cube` (Étape 5, 2026-09-17) +| # | Item | Statut | +|---|------|:------:| +| 3.1 | Buffers par entité (Transform + Matrix, uniform par slot) | ✅ | +| 3.2 | Compute matrices (compute shader : transform → world matrix) | ✅ | +| 3.3 | Indirect draws (`draw_args` GPU, `draw_indirect` / `draw_indexed_indirect`) | ✅ | +| 3.4 | Culling GPU (bounding sphere → frustum test → indirect args zéro) | ✅ | -### 1.4 Scene avec identifiants (MVP : String IDs) -- [x] `Scene` implémentée avec **String IDs** (`HashMap>`, `...Material`, entités) — état actuel validé ; décision : rester en String IDs pour le MVP -- [x] Méthodes : `add_mesh()`, `get_mesh()`, `add_material()`, `add_entity()`, `iter_entities()`, `remove_entity()` -- [x] Caméra active dans la `Scene` : `set_camera()` / `camera()` (Étape 4.3) -- [ ] **Reporté (étape "Handles typés")** : migrer vers `slotmap` générationnel (`MeshId`/`MaterialId`) quand l'éviction/les performances le justifieront +## Phase 4 — Rendu avancé ✅ -### 1.5 Rendu du Prototype -- [x] Uniform buffer pour la frame : `view`, `proj`, `cam_pos`, `light_dir` (Étapes 3+4) — écrit chaque frame depuis la caméra active -- [x] Uniform buffer par mesh : `world` (calculée sur CPU depuis `transform.to_matrix()`, Étape 4.2) -- [x] `Renderer::render_scene()` itère sur les entités de la Scene et dessine chacune (liaison bind groups frame+object) -- [x] Exemple fonctionnel : un cube éclairé tourne à l'écran — **fait** (Étape 5, 2026-09-17 : brancher `standard` sur l'exemple `cube` + mesh cube + rotation via `App::render_scene`) +| # | Item | Statut | +|---|------|:------:| +| 4.1 | Textures & matériaux (struct `Texture`, `Material`, bind groups, shader standard) | ✅ | +| 4.2 | Lighting & ombres (directional + point + spot + ambient, shadow mapping PCF) | ✅ | +| 4.3 | Batching & LOD (batching par matériau, LOD quadric edge collapse + hystérésis) | ✅ | +| 4.4 | **HDR + Tone Mapping** (offscreen `Rgba16Float` + fullscreen TM pass ACES/Reinhard) | ✅ | + +## Phase 5 — Qualité & polish ✅ + +| # | Item | Statut | +|---|------|:------:| +| 5.1 | Exemples (7 examples : hello_triangle → demo) | ✅ | +| 5.2 | Documentation (tech/ + user/ + rustdoc 100 %) | ✅ | +| 5.3 | Tests & robustesse (99 unit + 4 WGSL validation + 3 doctests) | ✅ | --- -## Phase 2️⃣ — Scène enrichie +## Phase 6 — Post-MVP ⬜ -**Objectif** : Étoffer la `Scene` au-delà du MVP. +> Au-delà du scope initial. Chaque item est opt-in et indépendant. -> Les ressources sont, pour le MVP, identifiées par **String IDs** (décision actée — voir Notes de -> Décision). Une migration vers des **handles typés** (slotmap générationnel) reste planifiée quand -> l'éviction/les performances le justifieront (voir 1.4, « reporté »). +| # | Item | Impact visuel | Effort | Statut | +|---|------|:---:|:---:|:---:| +| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ⬜ | +| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ⬜ | +| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ⬜ | +| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ⬜ | +| 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ | +| 6.6 | **Cascaded Shadow Maps** (2–3 cascades + blend, plus de précision près de la camera) | ⭐⭐ | Élevé | ⬜ | +| 6.7 | **SSAO** (ambient occlusion screen-space, depth + normal buffer) | ⭐⭐ | Élevé | ⬜ | -### 2.1 Camera dans la Scene -- [x] Intégrer `Camera` comme ressource de la Scene (Étape 4.3 : `Scene::set_camera` / `camera()`, caméra active unique) -- [ ] Permettre plusieurs caméras (actuelle/inactive) et une sélection par identifiant (`scene.set_active_camera(camera_id)`) -- [x] Exposer une caméra orbitale contrôlable (exemple final, Phase 5) — *(Étape 15.C, 2026-09-20 : `CameraController` orbitale pilotée par l'input unifié, branchée sur l'exemple `demo`)* +### Cibles techniques (refactoring) -### 2.2 Meshes primitifs (bibliothèque procédurale, WSGL) -- [x] Module `math::primitives` générant des `Geometry` prêts à l'emploi (positions + normales + UVs + indices) : `cube`, `plane`, `uv_sphere`, `icosphere`, `cylinder`, `cone` (et `torus` en bonus) — *(Étape 15.A, 2026-09-20 : implémenté, commit `4da89c7`)* -- [x] Factoriser le `cube_geometry` des exemples (`cube.rs`) vers `primitives::cube` — *(Étape 15.A : `cube.rs` et `spot_test.rs` utilisent désormais `math::cube(1.0)` ; `shadow_test.rs` garde son `box_geometry` générique)* -- [x] Tests unitaires : comptes de sommets/indices cohérents, normales unitaires orientées — *(6 tests dans `primitives.rs`)* - -### 2.3 Input unifié (clavier / souris / gamepad, WSGL) -- [x] Module `core::input` : `InputState` à sémantique cross-frame (pressed/held/released), consommation des `WindowEvent`/`DeviceEvent` winit, souris (position, delta, boutons, molette), clavier (touches), gamepad (v1 minimale optionnelle) — *(Étape 15.B, 2026-09-20, commit `b41f7e2` : clavier/souris/molette faits ; gamepad réservé/reporté)* -- [x] Boucle dans `App::run` (`begin_frame`/`end_frame`) + exposition `app.input()` / `app.input_mut()` — *(Étape 15.B : champs publics `app.input` + rotation `begin_frame`/`end_frame` autour de `update`)* -- [x] Contrôleur caméra orbitale (`CameraController`) construit sur l'input — *(Étape 15.C, 2026-09-20, prérequis de l'exemple final `demo`)* -- [~] Gamepad (v1 minimale optionnelle) — *(reporté, DRAFT D7 ; l'API d'input s'étendra sans rupture : champ `gamepad` réservé)* +| # | Item | Statut | +|---|------|:------:| +| 6.8 | Handles typés par ressource (slotmap) — `docs/tech/ARCHI_ARENES.md` | ⬜ | +| 6.9 | API update géométrie par entité (per-frame, sans rebuild complet) | ⬜ | +| 6.10 | Double-buffering des buffers Transform/Matrix (désync CPU/GPU) | ⬜ | +| 6.11 | **Module `mesh`** : primitives en features optionnelles + import (OBJ/gltf) — `math/` supprimé | ✅ | --- -## Phase 3️⃣ — GPU-Driven Rendering ✅ (2026-09-22) +## Liens -**Objectif** : Déléguer les calculs de transformation et culling au GPU (suivre ARCHI_CPU_GPU.md). -**Statut** : implémenté (Étape 17, validé 2026-09-22, décisions D1–D14 — référence durable : `docs/tech/ARCHI_CPU_GPU.md`, texte intégral du draft : git `3a424af`). Culling **désactivé par défaut** (non-régression), opt-in `AppBuilder::with_culling(true)`. **Correction 2026-09-22** : bug « fenêtre noire » avec culling ON (arguments de `select` WGSL écrits à la convention HLSL — toutes les entités visibles étaient remises à 0) ; corrigé et vérifié par readback GPU (D14). - -### 3.1 Compute Shader -- [x] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes (`TransformSlot`, 64 B) -- [x] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales (`MatSlot`, `STORAGE|UNIFORM`) -- [x] Compute shader : calcul des World Matrices pour tous les meshes (`compute_matrices`) - -### 3.2 Frustum Culling GPU -- [x] Ajouter `BBox` dans `Geometry` (coins min/max locaux) + `math::Frustum` (Gribb–Hartmann `[0,1]`) -- [x] Buffer `BoundingBoxBuffer` (CPU → GPU, ré-upload quand l'ensemble des meshes change, peu coûteux) -- [x] Compute shader : culling sphère vs frustum (`cull`), **désactivé par défaut** *(bug « fenêtre noire » corrigé le 2026-09-22 — ordre des arguments de `select` WGSL inversé ; cf. D14 dans `docs/tech/ARCHI_CPU_GPU.md`)* -- [x] Buffer `IndirectDrawBuffer` rempli par le GPU (`DrawSlot`, 80 B, zéro = no-op) - -### 3.3 Rendu Indirect -- [x] `draw_indexed_indirect()`/`draw_indirect()` au lieu de draw calls individuels -- [x] Un draw indirect **par slot actif** (décision D1 — pas un draw fusionné unique) ; le shadow pass est aussi indirect - ---- - -## Phase 4️⃣ — Fonctionnalités Avancées - -**Objectif** : Qualité visuelle et performances. - -### 4.1 Textures -- [x] Struct `Texture` avec chargement d'image *(Étape 10 : resources::Texture, from_rgba8/bytes/file, Rgba8UnormSrgb, sampler linear/repeat)* -- [x] Ajouter `uvs: Option>` dans `Geometry` *(prérequis Étape 10, déjà présent dans le code — seul l'échantillonnage manquait)* -- [x] BindGroup pour les textures dans le shader *(Étape 10 : groupe @2 sampler+texture sur toutes les pipelines, placeholder blanc)* -- [x] `Material` supporte une texture diffuse *(Étape 10 : Material.texture + texture_bind_group, placeholder si None)* - -### 4.2 Éclairage avancé -- [x] Lumières hémisphériques *(déjà dans le `standard_shader` : mélange hémisphérique, Étape 2)* -- [x] Support multi-lumières (directionnelles, ponctuelles) *(Étape 12, 2026-09-18 : liste globale dans la `Scene`, tableau `FrameUniforms.lights[8]`, shader accumule ambiant + directionnelles + ponctuelles, `MAX_LIGHTS = 8`)* -- [x] Lumières spot (cône + angle) *(Étape 13, 2026-09-18 : même struct `Light` + champ `dir_angle` (axe du cône + cos du demi-angle) + compteur `num_spot` ; boucle d'accumulation dédiée dans le shader avec pénombre lissée et atténuation linéaire ; `Scene::add_spot_light`)* -- [x] Shadows (optionnel) *(Étape 14, 2026-09-19 : shadow mapping mono-lumière — light unique - (directionnelle **ou** spot) choisie par `Scene::set_shadow_caster(index)` ; depth-only `shadow_shader.wgsl` - + pipeline ombre dans le `Renderer` (shadow map 1024² Depth32Float, bias slope-scaled) ; pass - `render_shadow_map` en tête de `render_scene` ; PCF 3×3 + comparateur dans `standard_shader.wgsl` - (groupe @3 partagé, lié mais non échantillonné quand désactivé → non-régression). Ombres **éteintes - par défaut**. Exemple `shadow_test` : cube projetant une ombre douce sur un sol.)* - -### 4.3 Optimisations -- [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé) -- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated`/`generate_lod_levels` : arêtes classées par coût quadrique, repli interne −2 faces / bordure −1, weld tolérance 1e-6 **conscient des attributs** (UV ≤ ½ tuile + normales dot > 0.9 — jamais à travers une seam/côte dure), mesh sans couture reste fermé, lèvres de couture protégées sinon (surface géométriquement complète), UVs/couleurs/normales interpolés au repli (normales héritées, jamais recalculées), rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame) -- [ ] HDR + Tone Mapping (optionnel) - -### 4.4 Gestion du Resize (cycle de vie Surface + Depth) -- [x] Handler `WindowEvent::Resized` dans `AppRunner::window_event` (`app.rs`) *(Étape 11, 2026-09-18)* - → recalculer `size`, prévenir de ne pas rendre tant que la taille est invalide (0). -- [x] Reconfigurer la surface (`Context::configure`) à la nouvelle taille. -- [x] Recréer la depth texture à la nouvelle taille (`Renderer::resize_depth(width, height)`) - — le helper `create_depth_texture` isolé (Étape 9, D3) rend ce recreate trivial. -- [x] Collecte du nouveau format si la configuration change (srgb etc.) → re-valider la compat pipeline. - *(D4 : `App::resize` compare l'ancien/nouveau format et re-synchronise Renderer (`set_format`) + Scene (`init_gpu`) ; cas pathologique, structuré non exercé couramment)* - -> Géré en Étape 11 (2026-09-18) : la surface est désormais reconfigurée à chaque `Resized` et la -> depth texture recréée en même temps (helper `create_depth_texture` isolé, Étape 9, D3). Le present -> mode FIFO reste figé (voir Notes de Décision). - ---- - -## Phase 5️⃣ — Documentation & Polish - -- [x] Exemple complet : mesh texturé, éclairé, avec caméra orbitale — *(Étape 15, 2026-09-20 : exemple `demo` — les 7 primitives, textures procédurales, 3 lumières, ombre portée, caméra orbitale live ; vérifié headless)* -- [x] Documentation API — *(Étape 16, 2026-07-19 : guide utilisateur `docs/user/` en français (8 pages interconnectées) + rustdoc complet sur toute l'API publique ; le `ARCHI_SCENE.md` séparé prévu est remplacé par les pages `docs/user/` + rustdoc — DRAFT Étape 16, décision D3)* -- [x] Tests unitaires : `Geometry`, `Scene`, `Transform` — *(Étape 16 : modules de tests ajoutés à `math/geometry.rs`, `math/transform.rs`, `scene/scene.rs` ; 28 tests unitaires + doctests au total, `cargo test --workspace` vert)* -- [x] README mis à jour avec les nouvelles fonctionnalités — *(Étape 16 : README racine re-ancré — workflow déclaratif = recommandé, manuel = avancé, `demo` = showcase, pollster 1.x ; README de modules `lib/src/**` à jour ; liens tech docs interconnectés)* - ---- - -## Notes de Décision - -| Décision | Raison | -|----------|--------| -| **Normals dès Phase 1** | Nécessaires pour le shader Phong ; sans elles, pas d'éclairage | -| **BBox en Phase 3** | Utile uniquement pour le frustum culling GPU | -| **World Matrix CPU → MVP, GPU → Phase 3** | Le MVP est plus simple avec un uniform par mesh ; la migration GPU-driven est progressive | -| **String IDs pour le MVP, slotmap reporté** | Le code et le README utilisent des String IDs (simples, sûrs, figés avant la boucle de rendu) ; `ARCHI_ARENES.md` reste la cible "handles typés" pour plus tard. La dépendance `slotmap` a été retirée tant qu'elle est inutilisée | -| **Present mode FIFO figé pour l'instant** | Le swapchain utilise `PresentMode::Fifo` avec `desired_maximum_frame_latency: 2` (double buffering vsync) — défaut sûr : pas de tearing, énergie minimale, zéro artefact. On **gèle ce choix** ; `Mailbox` (triple buffering) pourra être exposé en option et `Immediate` restera réservé à l'offscreen, **on s'occupera du present mode le moment venu** (quand le pipeline GPU-driven arrivera, Phase 3) — ce n'est pas bloquant pour les étapes 1-2 | -| **Resize géré (avec recréation de la depth texture), acté en D3 (2026-09-18), réalisé en Étape 11 (2026-09-18)** | L'app reconfigure désormais la surface et recrée la depth texture **en même temps** à chaque `Resized` (`App::resize` → `Context::configure` + `Renderer::resize_depth`), via le helper `create_depth_texture` isolé. Vérifié au runtime (exemple `cube`) : pas de crash, pas d'artefact, aspect correct | -| **WGSL `select(reject, accept, cond)`** | L'ordre des arguments est l'inverse de la convention HLSL : le **second** argument est retenu quand la condition est vraie. L'avoir écrit à la convention HLSL a produit le bug « fenêtre noire » du culling (comptes remis à 0 pour les entités visibles), corrigé le 2026-09-22 (D14). Piège documenté en tête de `gpu_driven.wgsl`, dans `AGENTS.md` et `docs/tech/ARCHI_CPU_GPU.md` | +- **Prochaine étape** : [DRAFT.md](DRAFT.md) (détail de l'étape en cours, remplacée à chaque itération) +- **Architecture** : [docs/tech/](tech/ARCHI_APP.md) +- **Utilisation** : [docs/user/](user/README.md) +- **Livre de recette** : [docs/PLAN.md](PLAN.md) diff --git a/docs/user/README.md b/docs/user/README.md index 67f4258..803c9d3 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -20,6 +20,7 @@ GPU graphics background is required. | [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures | | [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` | | [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall | +| [Mesh & primitives](mesh.md) | Procedural generators + file import (OBJ), feature-gated | | [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` | | [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling | | [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state | @@ -27,6 +28,31 @@ GPU graphics background is required. The pages are cross-linked: each page ends with a link to the next one. +## Design principle: opt-in = zero cost + +WSG follows a strict rule: **a feature you don't enable costs nothing at runtime**. + +| Feature | How to enable | If NOT enabled | +|---------|--------------|----------------| +| Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map allocated, no depth pass, no PCF sampling | +| HDR + Tone mapping | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass, direct-to-surface render | +| GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect draw buffers | +| LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh, no decimation, no hysteresis | +| Primitives | Cargo feature `prim-*` (default: all) | Not compiled at all | +| File import | Cargo feature `import-*` | Not compiled at all | + +The distinction matters: +- **Runtime opt-in** (shadows, HDR, culling, LOD): the code is compiled into your binary + but is **completely inert** if you never call the activation method. No GPU resources are + allocated, no passes execute, no per-frame overhead. The cost of the code being in the + binary is a few KB — negligible. +- **Compile-time opt-in** (primitives, import): the code is **not compiled at all** unless + you opt in via Cargo features. This matters when you want to minimize compile time or + binary size for a minimal build. + +You can mix both: build with `--no-default-features --features "prim-cube"` for a minimal +binary, then enable shadows/HDR at runtime only for the scenes that need them. + ## Links - Technical documentation (architecture): [ARCHI_APP](../tech/ARCHI_APP.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) · [ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_ARENES](../tech/ARCHI_ARENES.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) diff --git a/docs/user/mesh.md b/docs/user/mesh.md new file mode 100644 index 0000000..f14b8f2 --- /dev/null +++ b/docs/user/mesh.md @@ -0,0 +1,108 @@ +# Module `mesh` — Sources de géométrie + +Le module `wsg::mesh` est le point d'entrée unique pour **d'où vient la géométrie** : +générateurs procéduraux ou import de fichiers. + +## Primitives procédurales + +Chaque famille de primitives est derrière une **feature** — vous ne compilez que ce dont vous avez besoin. + +| Feature | Fonction | Description | +|---------|----------|-------------| +| `prim-cube` | `cube(size)` | Cube centré, 24 sommets, normales par face | +| `prim-plane` | `plane(w, d, seg_x, seg_z)` | Plan horizontal XZ (normale +Y), subdivisé | +| `prim-sphere` | `uv_sphere(r, sectors, stacks)` | Sphère lat/long, normales lisses | +| `prim-sphere` | `icosphere(r, subdivisions)` | Icosphère (subdiv icosahedron) | +| `prim-cylinder` | `cylinder(r, h, sectors)` | Cylindre (côté + caps), normales analytiques | +| `prim-cone` | `cone(r, h, sectors)` | Cône (apex + base fermée) | +| `prim-torus` | `torus(major, minor, seg_maj, seg_min)` | Tore, normales lisses | + +### Features par défaut + +```toml +# Cargo.toml de votre projet +[dependencies] +wsg-lib = { path = "../lib" } +# Default: toutes les primitives activées (all-prims) +``` + +```toml +# Ne compiler que le cube et la sphère : +wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube", "prim-sphere"] } +``` + +### Usage + +```rust +use wsg_lib::prelude::*; + +let cube = cube(2.0); +let sphere = uv_sphere(1.0, 32, 16); +let ico = icosphere(1.0, 2); + +// Tous retournent un Geometry (positions + normals + UVs + indices) +assert_eq!(cube.positions.len(), 24); +``` + +## Import de fichiers + +| Feature | Fonction | Format | +|---------|----------|--------| +| `import-obj` | `load_obj(path)` / `parse_obj(str)` | Wavefront OBJ | +| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB (stub) | + +### Parser OBJ + +Supporte : `v`, `vn`, `vt`, `f` (3-4 sommets, triangulation en éventail). +Si le fichier n'a pas de normales, elles sont **calculées** (pondération par aire). + +```rust +use wsg_lib::mesh::{load_obj, parse_obj}; + +// Depuis un fichier +let geom = load_obj("model.obj")?; + +// Depuis une string +let geom = parse_obj("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n")?; +``` + +### Erreurs + +```rust +use wsg_lib::mesh::import::MeshImportError; + +match load_obj("missing.obj") { + Ok(geom) => { /* … */ } + Err(MeshImportError::Io(e)) => eprintln!("fichier inaccessible: {e}"), + Err(MeshImportError::Parse(e)) => eprintln!("syntaxe invalide: {e}"), + Err(MeshImportError::Unsupported(e)) => eprintln!("feature non supportée: {e}"), +} +``` + +## De `Geometry` à la scène + +Le module `mesh` produit des `Geometry` (données CPU). Pour les rendre, +passez par `Scene::create_mesh` qui les transfère en GPU : + +```rust +use wsg_lib::prelude::*; +use wsg_lib::mesh::cube; + +// Dans AppHandler::setup : +let geom = cube(1.0); +app.scene.create_mesh("my_mesh", geom, Some("my_mat"))?; +app.scene.add_entity("my_entity", "my_mesh")?; +``` + +## Example + +```sh +cargo run -p wsg-lib --example import --features import-obj -- model.obj +``` + +## Convention + +- **Y-up**, origine centrée (sauf `plane` : plan XZ à y=0) +- Normales **sortantes** +- UVs dans [0,1]² +- Winding **CCW** (face avant) diff --git a/lib/Cargo.toml b/lib/Cargo.toml index a12ac98..ac43a92 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -6,6 +6,22 @@ edition = "2024" [lib] path = "src/lib.rs" +[features] +default = ["all-prims"] + +# Primitives procédurales (zéro dep externe) +prim-cube = [] +prim-plane = [] +prim-sphere = [] +prim-cylinder = [] +prim-cone = [] +prim-torus = [] +all-prims = ["prim-cube", "prim-plane", "prim-sphere", "prim-cylinder", "prim-cone", "prim-torus"] + +# Import de fichiers +import-obj = [] +import-gltf = [] + [dependencies] wgpu = "30.0.0" # Vérifiez la version la plus récente winit = "0.30.13" # For window management — pinned to match examples diff --git a/lib/examples/cube.rs b/lib/examples/cube.rs index 505ef28..635a867 100644 --- a/lib/examples/cube.rs +++ b/lib/examples/cube.rs @@ -13,7 +13,7 @@ use glam::{Quat, Vec3}; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; -use wsg_lib::math::cube; +use wsg_lib::mesh::cube; use wsg_lib::resources::Texture; use wsg_lib::utils::WsgError; diff --git a/lib/examples/demo.rs b/lib/examples/demo.rs index 478415e..9d04541 100644 --- a/lib/examples/demo.rs +++ b/lib/examples/demo.rs @@ -32,7 +32,8 @@ use winit::keyboard::KeyCode; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; use wsg_lib::core::ToneMapper; -use wsg_lib::math::{Transform, cone, cube, cylinder, icosphere, plane, torus, uv_sphere}; +use wsg_lib::core::Transform; +use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere}; use wsg_lib::resources::{CameraController, Texture}; use wsg_lib::utils::WsgError; diff --git a/lib/examples/import.rs b/lib/examples/import.rs new file mode 100644 index 0000000..6878b9c --- /dev/null +++ b/lib/examples/import.rs @@ -0,0 +1,66 @@ +//! # Example: File Import (OBJ) +//! +//! Demonstrates loading a Wavefront OBJ file with `wsg_lib::mesh::load_obj`. +//! Parses the file and prints geometry statistics. +//! +//! ## Build & Run +//! ```sh +//! cargo run -p wsg-lib --example import --features import-obj -- /path/to/model.obj +//! ``` +//! +//! Without a file argument, parses a built-in sample triangle. + +use wsg_lib::mesh::import::parse_obj; +use wsg_lib::mesh::load_obj; + +fn main() { + let args: Vec = std::env::args().collect(); + + let content = if args.len() > 1 { + let path = &args[1]; + eprintln!("Loading: {path}"); + match load_obj(path) { + Ok(geom) => { + print_stats(&geom); + return; + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + } else { + eprintln!("No file argument — parsing a built-in sample."); + eprintln!("Usage: import "); + // Built-in sample: a simple triangle with UVs and normals + "v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.5 1.0 0.0\nvn 0 0 1\nvt 0.0 0.0\nvt 1.0 0.0\nvt 0.5 1.0\nf 1/1/1 2/2/1 3/3/1\n" + }; + + let geom = parse_obj(content).expect("sample should parse"); + print_stats(&geom); +} + +fn print_stats(geom: &wsg_lib::Geometry) { + println!("\n=== Geometry Statistics ==="); + println!(" Vertices: {}", geom.positions.len()); + if let Some(n) = &geom.normals { + println!(" Normals: {}", n.len()); + } + if let Some(uv) = &geom.uvs { + println!(" UVs: {}", uv.len()); + } + if let Some(idx) = &geom.indices { + println!(" Indices: {} ({} triangles)", idx.len(), idx.len() / 3); + } + if let Err(e) = geom.validate() { + println!(" Validation FAILED: {e}"); + } else { + println!(" Validation: OK"); + } + // Bounding box + if let Some(bbox) = geom.bbox() { + println!(" BBox min: {:?}", bbox.min); + println!(" BBox max: {:?}", bbox.max); + } + println!(); +} diff --git a/lib/examples/shadow_test.rs b/lib/examples/shadow_test.rs index 2bf4639..e7cb1f1 100644 --- a/lib/examples/shadow_test.rs +++ b/lib/examples/shadow_test.rs @@ -104,7 +104,7 @@ impl wsg_lib::AppHandler for ShadowTest { .add_entity_with_transform( "ground", "ground_mesh", - wsg_lib::math::Transform::identity(), + wsg_lib::core::Transform::identity(), ) .unwrap(); @@ -112,7 +112,7 @@ impl wsg_lib::AppHandler for ShadowTest { app.scene .create_mesh("cube_mesh", box_geometry(0.5, 0.5, 0.5), Some("mat")) .unwrap(); - let mut cube_tf = wsg_lib::math::Transform::identity(); + let mut cube_tf = wsg_lib::core::Transform::identity(); cube_tf.translation = Vec3::new(0.0, 0.5, 0.0); app.scene .add_entity_with_transform("cube", "cube_mesh", cube_tf) diff --git a/lib/examples/spot_test.rs b/lib/examples/spot_test.rs index 263b2c3..39a782c 100644 --- a/lib/examples/spot_test.rs +++ b/lib/examples/spot_test.rs @@ -12,7 +12,7 @@ use glam::{Quat, Vec3}; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; -use wsg_lib::math::cube; +use wsg_lib::mesh::cube; use wsg_lib::utils::WsgError; /// Test handler: cube rotating slowly on two axes, lit **only** by a spot. diff --git a/lib/src/math/frustum.rs b/lib/src/core/frustum.rs similarity index 100% rename from lib/src/math/frustum.rs rename to lib/src/core/frustum.rs diff --git a/lib/src/math/geometry.rs b/lib/src/core/geometry.rs similarity index 99% rename from lib/src/math/geometry.rs rename to lib/src/core/geometry.rs index b61fc2e..24efa7e 100644 --- a/lib/src/math/geometry.rs +++ b/lib/src/core/geometry.rs @@ -182,7 +182,7 @@ impl Geometry { /// (normals, UVs, colors, indices) start as `None`. /// Chain builder methods to populate them: /// ``` - /// # use wsg_lib::math::Geometry; + /// # use wsg_lib::core::Geometry; /// let geo = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) /// .with_normals(vec![[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]]) /// .with_indices(vec![0, 1]); @@ -1511,7 +1511,7 @@ mod tests { #[test] fn decimated_icosahedron_stays_closed() { - use crate::math::primitives; + use crate::mesh::primitives; let ico = primitives::icosphere(1.0, 0); // 12 vertices / 20 faces, closed assert!(is_closed(&ico), "input is closed"); let out = ico.decimated(10); @@ -1531,7 +1531,7 @@ mod tests { fn decimated_uv_sphere_keeps_its_caps() { // Regression (user report): the old triangle-removal decimation removed the pole // triangles first (they are the smallest) → missing caps and holes. - use crate::math::primitives; + use crate::mesh::primitives; let sph = primitives::uv_sphere(1.0, 8, 6); let t = sph.num_triangles() as u32; assert!(is_closed(&sph), "input is closed"); @@ -1571,7 +1571,7 @@ mod tests { #[test] fn decimated_torus_stays_closed() { - use crate::math::primitives; + use crate::mesh::primitives; let torus = primitives::torus(1.0, 0.4, 16, 12); let t = torus.num_triangles() as u32; assert!(is_closed(&torus), "input is closed"); @@ -1635,7 +1635,7 @@ mod tests { #[test] fn decimated_is_deterministic() { - use crate::math::primitives; + use crate::mesh::primitives; let ico = primitives::icosphere(1.0, 1); // 80 equal-area faces let a = ico.decimated(30); let b = ico.decimated(30); @@ -1712,7 +1712,7 @@ mod tests { /// the inward-wound faces and pointed INWARD, deviation ≈ 2.0, far LOD looked flat). #[test] fn decimated_sphere_uv_and_normal_error() { - use crate::math::primitives; + use crate::mesh::primitives; let r = 0.55; let geo = primitives::uv_sphere(r, 32, 20); let levels = geo.generate_lod_levels(3); @@ -1774,7 +1774,7 @@ mod tests { // freezing the apex UV onto base vertices); the cone has TWO charts (side fan + // cap disc) and two slit columns at u = 0 / u = 1, so each decimated vertex is // checked against BOTH analytical maps and must follow at least one. - use crate::math::primitives; + use crate::mesh::primitives; let cone = primitives::cone(1.0, 1.0, 16); let levels = cone.generate_lod_levels(3); let udist = |a: f32, b: f32| { @@ -1957,7 +1957,7 @@ mod tests { /// duplicates separate, so decimating must never blend the charts together. #[test] fn decimated_cylinder_keeps_charts() { - use crate::math::primitives; + use crate::mesh::primitives; let cyl = primitives::cylinder(0.4, 0.9, 8); let out = cyl.decimated(16); assert!(out.validate().is_ok()); @@ -2064,7 +2064,7 @@ mod tests { #[test] fn generate_lod_levels_halving_ratios() { - use crate::math::primitives; + use crate::mesh::primitives; let geo = primitives::icosphere(1.0, 1); // 80 triangles let levels = geo.generate_lod_levels(3); assert_eq!(levels.len(), 3); @@ -2084,7 +2084,7 @@ mod tests { #[test] fn generate_lod_levels_one_level() { - use crate::math::primitives; + use crate::mesh::primitives; let geo = primitives::icosphere(1.0, 1); let levels = geo.generate_lod_levels(1); assert_eq!(levels.len(), 1); diff --git a/lib/src/math/lod.rs b/lib/src/core/lod.rs similarity index 100% rename from lib/src/math/lod.rs rename to lib/src/core/lod.rs diff --git a/lib/src/core/mod.rs b/lib/src/core/mod.rs index 0a2a96a..ef97db1 100644 --- a/lib/src/core/mod.rs +++ b/lib/src/core/mod.rs @@ -11,15 +11,23 @@ pub mod context; pub mod frame; +pub mod frustum; +pub mod geometry; pub mod hdr; pub mod input; +pub mod lod; pub mod renderer; pub mod shadow; +pub mod transform; // Re-exports pub use context::Context; pub use frame::Frame; +pub use frustum::Frustum; +pub use geometry::{BBox, Geometry, GeometryError}; pub use hdr::ToneMapper; pub use input::InputState; +pub use lod::{lod_level, projected_radius_px}; pub use renderer::Renderer; pub use shadow::ShadowConfig; +pub use transform::Transform; diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 9cb543e..36d23f5 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -21,8 +21,8 @@ use crate::core::Context; use crate::core::Frame; -use crate::math::Frustum; -use crate::math::lod::{lod_level, projected_radius_px}; +use crate::core::Frustum; +use crate::core::lod::{lod_level, projected_radius_px}; use crate::pipeline::{ DEPTH_FORMAT, build_shadow_pipeline, create_shadow_map_bind_group_layout, create_shadow_uniform_layout, create_uniform_bind_group_layouts, diff --git a/lib/src/math/transform.rs b/lib/src/core/transform.rs similarity index 100% rename from lib/src/math/transform.rs rename to lib/src/core/transform.rs diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 816671e..c0bf6be 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,9 +1,9 @@ //! # WSG Library Crate Root //! -//! The top-level entry point for the wsg-lib crate. Exposes seven public modules organized by architectural responsibility: -//! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor layers), -//! **resources** (data types), **pipeline** (shader compilation cache), **scene** (resource graph and entity management), -//! and **utils** (configuration and error handling). +//! The top-level entry point for the wsg-lib crate. Exposes eight public modules organized by architectural responsibility: +//! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor + geometry types), +//! **mesh** (geometry sources: primitives + import), **resources** (data types), **pipeline** (shader compilation cache), +//! **scene** (resource graph and entity management), **prelude** (glob re-exports), and **utils** (configuration and error handling). //! //! ## Module Interaction Map //! - `core` consumes resources from `resources`, pipelines from `pipeline`, and errors from `utils`. @@ -31,8 +31,9 @@ pub mod app; pub mod core; pub mod handler; -pub mod math; +pub mod mesh; pub mod pipeline; +pub mod prelude; pub mod resources; pub mod scene; pub mod utils; @@ -52,3 +53,12 @@ pub use crate::core::ShadowConfig; /// Re-export of the tone mapping curve selector for convenient top-level access. /// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`. pub use crate::core::ToneMapper; + +/// Re-export of the geometry data type (positions, normals, UVs, indices). +pub use crate::core::Geometry; + +/// Re-export of the per-entity transform (position + rotation + scale). +pub use crate::core::Transform; + +/// Re-export of the axis-aligned bounding box. +pub use crate::core::BBox; diff --git a/lib/src/math/mod.rs b/lib/src/math/mod.rs deleted file mode 100644 index 16fd898..0000000 --- a/lib/src/math/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! # Math Module — Geometric and Transformation Utilities -//! -//! Provides core mathematical types and utilities for 3D graphics operations, including: -//! - `Transform` for object positioning, rotation, and scaling -//! - `Geometry` for mesh vertex data representation -//! - `primitives` for procedural mesh generators (cube, plane, sphere, cylinder, cone, torus) -//! -//! ## Interaction with Other Modules -//! - `scene::Scene` uses `Transform` to manage entity positions -//! - `renderer::Renderer` uses `Transform` to compute world matrices for shaders -//! - `resources::Mesh` stores vertex data in `Geometry` format -//! - `resources::Camera` (view/projection matrices) lives in the `resources` module -//! -//! ## Files -//! - `transform.rs`: Defines the `Transform` struct and its conversion to matrix form -//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage -//! - `primitives.rs`: Procedural mesh generators (cube, sphere, cylinder, cone, torus…) returning `Geometry` -//! - `lod.rs`: Pure LOD level-selection functions (projected radius + hysteresis, Step 19) - -pub mod frustum; -pub mod geometry; -pub mod lod; -pub mod primitives; -pub mod transform; - -// Re-exports -pub use frustum::Frustum; -pub use geometry::{BBox, Geometry, GeometryError}; -pub use lod::{lod_level, projected_radius_px}; -pub use primitives::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere}; -pub use transform::Transform; diff --git a/lib/src/math/primitives.rs b/lib/src/math/primitives.rs deleted file mode 100644 index 0134d7e..0000000 --- a/lib/src/math/primitives.rs +++ /dev/null @@ -1,534 +0,0 @@ -//! # Primitives Module — Ready-to-use geometry meshes (Step 15, ROADMAP 2.2) -//! -//! Procedural `Geometry` generators for common 3D shapes, usable directly in WSG -//! without importing wgpu: `cube`, `plane`, `uv_sphere`, `icosphere`, -//! `cylinder`, `cone` (and `torus` as a bonus). -//! -//! ## Conventions -//! - **Y-up** axis, origin-centered (except `plane`, which lies in the XZ plane around 0). -//! - Normals **pointing outward** (meaningful for Phong lighting; culling stays disabled -//! by default). -//! - UVs in [0,1]², as continuous as possible; `uv_sphere`/`icosphere` project from -//! spherical coordinates. -//! - Each generator returns a **complete** `Geometry` (positions + normals + UVs + -//! indices, no colors → opaque white default via `Geometry::to_vertices`). -//! -//! ## Invariant -//! Every produced geometry passes `Geometry::validate()` without error (checked by the tests). - -use crate::math::Geometry; -use glam::Vec3; -use std::collections::HashMap; - -/// Generates a cube centered at the origin, edge `size`, with one normal and UVs per face. -/// 24 vertices (4 per face) + 36 indices. Replicates exactly the historical `cube_geometry` of -/// the examples (Step 5/10) to guarantee non-regression. -pub fn cube(size: f32) -> Geometry { - let s = size * 0.5; // half edge - let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ - ( - [0.0, 0.0, 1.0], - [[-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]], - ), // +Z - ( - [0.0, 0.0, -1.0], - [[s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]], - ), // -Z - ( - [1.0, 0.0, 0.0], - [[s, -s, -s], [s, s, -s], [s, s, s], [s, -s, s]], - ), // +X - ( - [-1.0, 0.0, 0.0], - [[-s, -s, s], [-s, s, s], [-s, s, -s], [-s, -s, -s]], - ), // -X - ( - [0.0, 1.0, 0.0], - [[-s, s, -s], [s, s, -s], [s, s, s], [-s, s, s]], - ), // +Y - ( - [0.0, -1.0, 0.0], - [[-s, -s, s], [s, -s, s], [s, -s, -s], [-s, -s, -s]], - ), // -Y - ]; - - let mut positions = Vec::with_capacity(24); - let mut normals = Vec::with_capacity(24); - let mut uvs = Vec::with_capacity(24); - let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; - for (normal, corners) in faces { - for (i, corner) in corners.iter().enumerate() { - positions.push(*corner); - normals.push(normal); - uvs.push(quad_uvs[i]); - } - } - - let mut indices = Vec::with_capacity(36); - for face in 0..6u16 { - let b = face * 4; - indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]); - } - - Geometry::new(positions) - .with_normals(normals) - .with_uvs(uvs) - .with_indices(indices) -} - -/// Generates a horizontal plane in the XZ plane (normal +Y), centered at (0, 0, 0), with -/// `width` × `depth` dimensions, subdivided into `seg_x` × `seg_z` cells. UVs stretched over [0,1]². -pub fn plane(width: f32, depth: f32, seg_x: u32, seg_z: u32) -> Geometry { - let sx = seg_x.max(1); - let sz = seg_z.max(1); - let (mut positions, mut normals, mut uvs, mut indices) = ( - Vec::<[f32; 3]>::new(), - Vec::<[f32; 3]>::new(), - Vec::<[f32; 2]>::new(), - Vec::::new(), - ); - for z in 0..=sz { - let vz = z as f32 / sz as f32; - for x in 0..=sx { - let vx = x as f32 / sx as f32; - positions.push([(vx - 0.5) * width, 0.0, (vz - 0.5) * depth]); - normals.push([0.0, 1.0, 0.0]); - uvs.push([vx, vz]); - } - } - for z in 0..sz { - for x in 0..sx { - let a = z * (sx + 1) + x; - let b = a + 1; - let c = (z + 1) * (sx + 1) + x; - let d = c + 1; - indices - .extend_from_slice(&[a as u16, c as u16, b as u16, b as u16, c as u16, d as u16]); - } - } - Geometry::new(positions) - .with_normals(normals) - .with_uvs(uvs) - .with_indices(indices) -} - -/// Generates a UV (latitude/longitude) sphere of radius `radius`, with `sectors` segments around -/// and `stacks` vertical rings. Smooth normals = normalized position; spherical UVs. -pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Geometry { - let si = sectors.max(3); - let st = stacks.max(3); - let (mut positions, mut normals, mut uvs, mut indices) = ( - Vec::<[f32; 3]>::new(), - Vec::<[f32; 3]>::new(), - Vec::<[f32; 2]>::new(), - Vec::::new(), - ); - for stack in 0..=st { - let v = stack as f32 / st as f32; - let phi = v * std::f32::consts::PI; - for sector in 0..=si { - let u = sector as f32 / si as f32; - let theta = u * 2.0 * std::f32::consts::PI; - let (sin_p, cos_p) = phi.sin_cos(); - let (sin_t, cos_t) = theta.sin_cos(); - let pos = Vec3::new( - radius * sin_p * cos_t, - radius * cos_p, - radius * sin_p * sin_t, - ); - positions.push(pos.to_array()); - normals.push(pos.normalize().to_array()); - uvs.push([u, v]); - } - } - for stack in 0..st { - for sector in 0..si { - let k1 = stack * (si + 1) + sector; - let k2 = k1 + si + 1; - let (k1, k2) = (k1 as u16, k2 as u16); - indices.extend_from_slice(&[k1, k2, k1 + 1, k1 + 1, k2, k2 + 1]); - } - } - Geometry::new(positions) - .with_normals(normals) - .with_uvs(uvs) - .with_indices(indices) -} - -/// Generates an icosphere (subdivided icosahedron) of radius `radius`. `subdivisions = 0` gives -/// an icosahedron (12 vertices / 20 faces / 60 indices); each subdivision refines the faces into 4. -/// Smooth normals = position direction; spherical UVs (a seam is unavoidable without a UV -/// atlas). -pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry { - let t = (1.0 + 5.0_f32.sqrt()) * 0.5; - // 12 unit vertices (canonical icosahedron). - let mut positions: Vec = [ - [-1.0, t, 0.0], - [1.0, t, 0.0], - [-1.0, -t, 0.0], - [1.0, -t, 0.0], - [0.0, -1.0, t], - [0.0, 1.0, t], - [0.0, -1.0, -t], - [0.0, 1.0, -t], - [t, 0.0, -1.0], - [t, 0.0, 1.0], - [-t, 0.0, -1.0], - [-t, 0.0, 1.0], - ] - .iter() - .map(|v| Vec3::from_array(*v).normalize()) - .collect(); - - let mut faces: Vec<[u32; 3]> = [ - [0, 11, 5], - [0, 5, 1], - [0, 1, 7], - [0, 7, 10], - [0, 10, 11], - [1, 5, 9], - [5, 11, 4], - [11, 10, 2], - [10, 7, 6], - [7, 1, 8], - [3, 9, 4], - [3, 4, 2], - [3, 2, 6], - [3, 6, 8], - [3, 8, 9], - [4, 9, 5], - [2, 4, 11], - [6, 2, 10], - [8, 6, 7], - [9, 8, 1], - ] - .into_iter() - .map(|[a, b, c]| [a, b, c]) - .collect(); - - for _ in 0..subdivisions { - let mut midpoint = HashMap::new(); - let old_faces = std::mem::take(&mut faces); - for [a, b, c] in old_faces { - let ab = subdiv_midpoint(&mut positions, &mut midpoint, a, b); - let bc = subdiv_midpoint(&mut positions, &mut midpoint, b, c); - let ca = subdiv_midpoint(&mut positions, &mut midpoint, c, a); - faces.push([a, ab, ca]); - faces.push([ab, b, bc]); - faces.push([ca, bc, c]); - faces.push([ab, bc, ca]); - } - } - - // Scale to the radius + normals (unit direction) + spherical UVs. - let mut normals = Vec::with_capacity(positions.len()); - let mut uvs = Vec::with_capacity(positions.len()); - for p in &positions { - let dir = p.normalize(); - normals.push(dir.to_array()); - uvs.push(spherical_uv(dir)); - } - let scaled: Vec<[f32; 3]> = positions.iter().map(|p| (*p * radius).to_array()).collect(); - - let mut indices = Vec::with_capacity(faces.len() * 3); - for [a, b, c] in &faces { - indices.extend_from_slice(&[*a as u16, *b as u16, *c as u16]); - } - Geometry::new(scaled) - .with_normals(normals) - .with_uvs(uvs) - .with_indices(indices) -} - -/// Creates (or retrieves) the normalized midpoint between `a` and `b`, pushed onto the unit sphere. -fn subdiv_midpoint( - positions: &mut Vec, - cache: &mut HashMap<(u32, u32), u32>, - a: u32, - b: u32, -) -> u32 { - let key = if a < b { (a, b) } else { (b, a) }; - if let Some(&i) = cache.get(&key) { - return i; - } - let mid = (positions[a as usize] + positions[b as usize]).normalize(); - positions.push(mid); - let i = (positions.len() - 1) as u32; - cache.insert(key, i); - i -} - -/// Spherical UV from a unit direction, in [0,1]². -fn spherical_uv(dir: Vec3) -> [f32; 2] { - let u = 0.5 + (dir.z.atan2(dir.x) / (2.0 * std::f32::consts::PI)); - let v = 0.5 - (dir.y.asin() / std::f32::consts::PI); - [u, v] -} - -/// Generates a cylinder of radius `radius` and height `height` (along Y, centered), with -/// `sectors` segments. Parts: side (smooth radial normals), top cap (+Y), bottom base -/// (-Y). Side UVs stretched over [0,1]², concentric rings merged on the caps. -pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry { - let si = sectors.max(3); - let h = height * 0.5; - let (mut positions, mut normals, mut uvs, mut indices) = ( - Vec::<[f32; 3]>::new(), - Vec::<[f32; 3]>::new(), - Vec::<[f32; 2]>::new(), - Vec::::new(), - ); - - // Side: radial columns × 2 rows (bottom/top). - let side_base = 0u16; - for row in 0..=1 { - let y = if row == 0 { -h } else { h }; - for s in 0..=si { - let u = s as f32 / si as f32; - let theta = u * 2.0 * std::f32::consts::PI; - let (sin_t, cos_t) = theta.sin_cos(); - let radial = Vec3::new(cos_t, 0.0, sin_t); - positions.push((radial * radius + Vec3::new(0.0, y, 0.0)).to_array()); - normals.push(radial.to_array()); - uvs.push([u, row as f32]); - } - } - for s in 0..si { - let a = side_base + s as u16; - let b = a + 1; - let c = side_base + (si as u16) + 1 + s as u16; - let d = c + 1; - indices.extend_from_slice(&[a, c, b, b, c, d]); - } - - // Caps: center + ring at each end. - for (y, normal) in [(h, [0.0, 1.0, 0.0]), (-h, [0.0, -1.0, 0.0])] { - let center = positions.len() as u16; - positions.push([0.0, y, 0.0]); - normals.push(normal); - uvs.push([0.5, 0.5]); - let ring_start = positions.len() as u16; - for s in 0..=si { - let u = s as f32 / si as f32; - let theta = u * 2.0 * std::f32::consts::PI; - let (sin_t, cos_t) = theta.sin_cos(); - positions.push([radius * cos_t, y, radius * sin_t]); - normals.push(normal); - uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]); - } - for s in 0..si { - let a = ring_start + s as u16; - indices.extend_from_slice(&[center, a + 1, a]); - } - } - - Geometry::new(positions) - .with_normals(normals) - .with_uvs(uvs) - .with_indices(indices) -} - -/// Generates a cone of radius `radius` and height `height` (apex at +h/2, base at -h/2), closed by a -/// base, with `sectors` segments. Analytical side normals (tilted outward); -/// base normal −Y. -pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry { - let si = sectors.max(3); - let h = height * 0.5; - let (mut positions, mut normals, mut uvs, mut indices) = ( - Vec::<[f32; 3]>::new(), - Vec::<[f32; 3]>::new(), - Vec::<[f32; 2]>::new(), - Vec::::new(), - ); - - // Side elements: apex + base ring. - let apex = 0u16; - positions.push([0.0, h, 0.0]); - normals.push([0.0, 1.0, 0.0]); // shared apex; normal close to +Y by default - uvs.push([0.5, 1.0]); - let base_start = 1u16; - for s in 0..=si { - let u = s as f32 / si as f32; - let theta = u * 2.0 * std::f32::consts::PI; - let (sin_t, cos_t) = theta.sin_cos(); - positions.push([radius * cos_t, -h, radius * sin_t]); - // Side normal: normalize(h·cosθ, r, h·sinθ). - let n = Vec3::new(h * cos_t, radius, h * sin_t).normalize(); - normals.push(n.to_array()); - uvs.push([u, 0.0]); - } - for s in 0..si { - indices.extend_from_slice(&[apex, base_start + s as u16 + 1, base_start + s as u16]); - } - - // Closed base (circle at -h/2, normal -Y). - let center = positions.len() as u16; - positions.push([0.0, -h, 0.0]); - normals.push([0.0, -1.0, 0.0]); - uvs.push([0.5, 0.5]); - let ring = positions.len() as u16; - for s in 0..=si { - let u = s as f32 / si as f32; - let theta = u * 2.0 * std::f32::consts::PI; - let (sin_t, cos_t) = theta.sin_cos(); - positions.push([radius * cos_t, -h, radius * sin_t]); - normals.push([0.0, -1.0, 0.0]); - uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]); - } - for s in 0..si { - let r = ring + s as u16; - indices.extend_from_slice(&[center, r, r + 1]); - } - - Geometry::new(positions) - .with_normals(normals) - .with_uvs(uvs) - .with_indices(indices) -} - -/// Generates a torus (ring) with major radius `major` (tube center) and minor radius `minor` -/// (tube radius), subdivided into `major_segments` × `minor_segments`. Smooth normals (tube -/// direction); UVs [0,1]² (seam along the tube meridian and equator). -pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -> Geometry { - let mj = major_segments.max(3); - let mn = minor_segments.max(3); - let (mut positions, mut normals, mut uvs, mut indices) = ( - Vec::<[f32; 3]>::new(), - Vec::<[f32; 3]>::new(), - Vec::<[f32; 2]>::new(), - Vec::::new(), - ); - for i in 0..=mj { - let u = i as f32 / mj as f32; - let ua = u * 2.0 * std::f32::consts::PI; - let (sin_u, cos_u) = ua.sin_cos(); - for j in 0..=mn { - let v = j as f32 / mn as f32; - let va = v * 2.0 * std::f32::consts::PI; - let (sin_v, cos_v) = va.sin_cos(); - let ring = Vec3::new( - (major + minor * cos_v) * cos_u, - minor * sin_v, - (major + minor * cos_v) * sin_u, - ); - positions.push(ring.to_array()); - let n = Vec3::new(cos_v * cos_u, sin_v, cos_v * sin_u).normalize(); - normals.push(n.to_array()); - uvs.push([u, v]); - } - } - for i in 0..mj { - for j in 0..mn { - let a = i * (mn + 1) + j; - let b = a + 1; - let c = a + mn + 1; - let d = c + 1; - // Triangles [a, b, c] / [b, d, c]: on the surface, angle u (major) grows with +u and - // angle v (minor) grows with +v; cross(tang_u, tang_v) points OUTWARD from - // the tube (= the stored normal), so the winding is CCW seen from outside — - // consistent with `front_face: Face::Ccw` (back-face culling). - // The original [a, c, b] order was inverted: the external face (CCW seen from outside, - // outward normal) was culled and only the inside of the tube, whose normals - // point outward, stayed visible — the torus appeared black (N·L ≤ 0). - indices - .extend_from_slice(&[a as u16, b as u16, c as u16, b as u16, d as u16, c as u16]); - } - } - Geometry::new(positions) - .with_normals(normals) - .with_uvs(uvs) - .with_indices(indices) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn assert_valid(geo: &Geometry) { - geo.validate().expect("generated geometry must validate"); - let positions = &geo.positions; - let normals = geo.normals.as_ref().expect("normals present"); - let uvs = geo.uvs.as_ref().expect("uvs present"); - let indices = geo.indices.as_ref().expect("indices present"); - assert_eq!(normals.len(), positions.len(), "normals/positions count"); - assert_eq!(uvs.len(), positions.len(), "uvs/positions count"); - for n in normals { - let len = Vec3::from_array(*n).length(); - assert!((len - 1.0).abs() < 1e-3, "unit normal, got {len}"); - } - for &i in indices { - assert!((i as usize) < positions.len(), "index {i} in bounds"); - } - } - - #[test] - fn cube_counts() { - let g = cube(1.0); - assert_eq!(g.positions.len(), 24); - assert_eq!(g.indices.as_ref().unwrap().len(), 36); - assert_valid(&g); - let g2 = cube(2.0); - assert_eq!( - g2.positions, - g.positions - .iter() - .map(|p| [p[0] * 2.0, p[1] * 2.0, p[2] * 2.0]) - .collect::>() - ); - } - - #[test] - fn plane_counts() { - let g = plane(2.0, 3.0, 1, 1); - assert_eq!(g.positions.len(), 4); - assert_eq!(g.indices.as_ref().unwrap().len(), 6); - assert_valid(&g); - assert!(g.positions.iter().all(|p| p[1] == 0.0)); - let g2 = plane(2.0, 3.0, 4, 5); - assert_eq!(g2.positions.len(), (4 + 1) * (5 + 1)); - assert_valid(&g2); - } - - #[test] - fn uv_sphere_counts_and_normals() { - let g = uv_sphere(1.0, 12, 8); - assert_eq!(g.positions.len(), (12 + 1) * (8 + 1)); - assert_valid(&g); - // Normals point outward (position/radius). - for (p, n) in g.positions.iter().zip(g.normals.as_ref().unwrap()) { - let diff = (Vec3::from_array(*p) / 1.0 - Vec3::from_array(*n)).length(); - assert!(diff < 1e-4, "normal ~ position/radius, got diff {diff}"); - } - } - - #[test] - fn icosphere_grows_with_subdivision() { - let base = icosphere(1.0, 0); - assert_eq!(base.positions.len(), 12); - assert_eq!(base.indices.as_ref().unwrap().len(), 60); - assert_valid(&base); - let once = icosphere(1.0, 1); - assert!(once.positions.len() > base.positions.len()); - assert_valid(&once); - for (p, n) in once.positions.iter().zip(once.normals.as_ref().unwrap()) { - let r = Vec3::from_array(*p).length(); - assert!((r - 1.0).abs() < 1e-3, "on sphere radius, got {r}"); - let diff = (Vec3::from_array(*p).normalize() - Vec3::from_array(*n)).length(); - assert!(diff < 1e-4, "normal ~ direction, got {diff}"); - } - } - - #[test] - fn cylinder_and_cone_validate() { - assert_valid(&cylinder(0.5, 1.0, 16)); - assert_valid(&cone(0.5, 1.0, 16)); - let c = cylinder(0.5, 1.0, 8); - assert!(c.positions.iter().all(|p| p[1].abs() <= 0.5 + 1e-5)); - } - - #[test] - fn torus_validate() { - let g = torus(1.0, 0.25, 24, 12); - assert_valid(&g); - assert_eq!(g.positions.len(), (24 + 1) * (12 + 1)); - } -} diff --git a/lib/src/mesh/import/gltf.rs b/lib/src/mesh/import/gltf.rs new file mode 100644 index 0000000..2cc8c06 --- /dev/null +++ b/lib/src/mesh/import/gltf.rs @@ -0,0 +1,20 @@ +//! glTF 2.0 / GLB loader. +//! +//! **Status: stub** — the full implementation requires the `gltf` crate and will +//! be added in a follow-up. For now, this module compiles (behind `feature = "import-gltf"`) +//! and returns a clear error. + +use crate::core::geometry::Geometry; +use crate::mesh::import::MeshImportError; +use std::path::Path; + +/// Loads a glTF 2.0 (.gltf JSON) or GLB (.glb binary) file. +/// +/// # Errors +/// Always returns [`MeshImportError::Unsupported`] for now (implementation pending). +pub fn load_gltf(path: impl AsRef) -> Result, MeshImportError> { + let _ = path; + Err(MeshImportError::Unsupported( + "glTF import is not yet implemented (pending gltf crate wrapper)".into(), + )) +} diff --git a/lib/src/mesh/import/mod.rs b/lib/src/mesh/import/mod.rs new file mode 100644 index 0000000..5e5948d --- /dev/null +++ b/lib/src/mesh/import/mod.rs @@ -0,0 +1,31 @@ +//! File import loaders — each behind a feature flag. +//! +//! | Feature | Function | Format | +//!|---------|----------|--------| +//!| `import-obj` | `load_obj(path)` | Wavefront OBJ | +//!| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB | + +#[cfg(feature = "import-obj")] +pub mod obj; +#[cfg(feature = "import-gltf")] +#[path = "gltf.rs"] +pub mod gltf_loader; + +#[cfg(feature = "import-obj")] +pub use obj::{load_obj, parse_obj}; +#[cfg(feature = "import-gltf")] +pub use gltf_loader::load_gltf; + +/// Error type for mesh file import. +#[derive(thiserror::Error, Debug)] +pub enum MeshImportError { + /// The file could not be read (I/O error). + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + /// The file content is malformed or cannot be parsed. + #[error("parse error: {0}")] + Parse(String), + /// The file uses features not supported by this loader. + #[error("unsupported format: {0}")] + Unsupported(String), +} diff --git a/lib/src/mesh/import/obj.rs b/lib/src/mesh/import/obj.rs new file mode 100644 index 0000000..30b8333 --- /dev/null +++ b/lib/src/mesh/import/obj.rs @@ -0,0 +1,311 @@ +//! Wavefront OBJ parser — minimal, dependency-free. +//! +//! Supports: `v` (position), `vn` (normal), `vt` (UV), `f` (face, 3-4 verts). +//! Quads are split into triangles via fan triangulation. + +use crate::core::geometry::Geometry; +use crate::mesh::import::MeshImportError; +use std::path::Path; + +/// Parses a Wavefront OBJ file and returns a single [`Geometry`]. +/// +/// Supported directives: `v`, `vn`, `vt`, `f` (3 or 4 vertices per face). +/// Vertex references in `f` use 1-based indices. +/// If no `vn` lines are present, normals are computed (area-weighted face normals). +/// If no `vt` lines are present, UVs are omitted. +/// +/// # Errors +/// Returns [`MeshImportError::Io`] if the file cannot be read, +/// or [`MeshImportError::Parse`] on malformed input. +pub fn load_obj(path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path).map_err(MeshImportError::Io)?; + parse_obj(&content) +} + +/// Parses OBJ content from a string. See [`load_obj`] for supported features. +pub fn parse_obj(content: &str) -> Result { + let mut positions: Vec<[f32; 3]> = Vec::new(); + let mut file_normals: Vec<[f32; 3]> = Vec::new(); + let mut file_uvs: Vec<[f32; 2]> = Vec::new(); + + // Unique vertex table: (pos_idx, opt_uv_idx, opt_norm_idx) + let mut vert_table: Vec<(usize, Option, Option)> = Vec::new(); + let mut indices: Vec = Vec::new(); + + for (line_num, raw) in content.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + match parts[0] { + "v" => { + if parts.len() < 4 { + return Err(MeshImportError::Parse(format!( + "line {}: 'v' needs 3+ components", + line_num + 1 + ))); + } + let x = parts[1].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'v' x = '{}'", line_num + 1, parts[1])) + })?; + let y = parts[2].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'v' y = '{}'", line_num + 1, parts[2])) + })?; + let z = parts[3].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'v' z = '{}'", line_num + 1, parts[3])) + })?; + positions.push([x, y, z]); + } + "vn" => { + if parts.len() < 4 { + return Err(MeshImportError::Parse(format!( + "line {}: 'vn' needs 3+ components", + line_num + 1 + ))); + } + let x = parts[1].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'vn' x", line_num + 1)) + })?; + let y = parts[2].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'vn' y", line_num + 1)) + })?; + let z = parts[3].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'vn' z", line_num + 1)) + })?; + file_normals.push([x, y, z]); + } + "vt" => { + if parts.len() < 3 { + return Err(MeshImportError::Parse(format!( + "line {}: 'vt' needs 2+ components", + line_num + 1 + ))); + } + let u = parts[1].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'vt' u", line_num + 1)) + })?; + let v = parts[2].parse::().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad 'vt' v", line_num + 1)) + })?; + file_uvs.push([u, v]); + } + "f" => { + if parts.len() < 4 { + return Err(MeshImportError::Parse(format!( + "line {}: 'f' needs 3+ vertices, got {}", + line_num + 1, + parts.len() - 1 + ))); + } + // Parse vertex references: "idx" or "idx/uv" or "idx/uv/norm" + let face_verts: Vec<(usize, Option, Option)> = parts[1..] + .iter() + .map(|tok| { + let mut fields = tok.split('/'); + let idx_str = fields.next().unwrap_or("0"); + let uv_str = fields.next(); + let norm_str = fields.next(); + + let idx: usize = idx_str.parse().map_err(|_| { + MeshImportError::Parse(format!( + "line {}: bad face vertex index '{}'", + line_num + 1, + tok + )) + })?; + if idx == 0 { + return Err(MeshImportError::Parse(format!( + "line {}: 0-based index not allowed in face", + line_num + 1 + ))); + } + let uv_idx = parse_opt_idx(uv_str, line_num, "uv")?; + let norm_idx = parse_opt_idx(norm_str, line_num, "norm")?; + Ok((idx - 1, uv_idx, norm_idx)) + }) + .collect::>()?; + + // Map to unique vertex indices (dedup by pos+uv+norm tuple) + let mapped: Vec = face_verts + .iter() + .map(|&(pi, uvi, ni)| { + // Check if this combo already exists + if let Some(pos) = vert_table.iter().position(|&(ep, eu, en)| { + ep == pi && eu == uvi && en == ni + }) { + pos as u16 + } else { + vert_table.push((pi, uvi, ni)); + (vert_table.len() - 1) as u16 + } + }) + .collect(); + + // Fan triangulation + if mapped.len() == 3 { + indices.extend_from_slice(&mapped); + } else if mapped.len() > 3 { + for i in 1..mapped.len() - 1 { + indices.extend_from_slice(&[mapped[0], mapped[i], mapped[i + 1]]); + } + } + } + _ => {} // Ignore unknown directives + } + } + + if positions.is_empty() { + return Err(MeshImportError::Parse("no vertices found".into())); + } + if vert_table.is_empty() { + return Err(MeshImportError::Parse("no faces found".into())); + } + + // Build output vertex arrays from the table + let mut out_positions = Vec::with_capacity(vert_table.len()); + let mut out_normals = Vec::with_capacity(vert_table.len()); + let mut out_uvs = Vec::with_capacity(vert_table.len()); + let mut has_any_uv = false; + + for &(pi, uvi, ni) in &vert_table { + out_positions.push(positions[pi]); + if let Some(ni) = ni { + out_normals.push(file_normals[ni]); + } else { + out_normals.push([0.0, 0.0, 0.0]); + } + if let Some(uvi) = uvi { + out_uvs.push(file_uvs[uvi]); + has_any_uv = true; + } else { + out_uvs.push([0.0, 0.0]); + } + } + + // Compute normals if file had none + if file_normals.is_empty() { + compute_normals(&out_positions, &indices, &mut out_normals); + } + + let mut geo = Geometry::new(out_positions) + .with_normals(out_normals) + .with_indices(indices); + if has_any_uv { + geo = geo.with_uvs(out_uvs); + } + + geo.validate() + .map_err(|e| MeshImportError::Parse(format!("validation failed: {e}")))?; + Ok(geo) +} + +fn parse_opt_idx( + field: Option<&str>, + line_num: usize, + what: &str, +) -> Result, MeshImportError> { + match field { + None | Some("") => Ok(None), + Some(s) => { + let idx: usize = s.parse().map_err(|_| { + MeshImportError::Parse(format!("line {}: bad {what} index '{s}'", line_num + 1)) + })?; + if idx == 0 { + return Err(MeshImportError::Parse(format!( + "line {}: 0-based {what} index", + line_num + 1 + ))); + } + Ok(Some(idx - 1)) + } + } +} + +/// Computes area-weighted vertex normals from triangle faces. +fn compute_normals(positions: &[[f32; 3]], indices: &[u16], normals: &mut [[f32; 3]]) { + use glam::Vec3; + for n in normals.iter_mut() { + *n = [0.0, 0.0, 0.0]; + } + for tri in indices.chunks(3) { + if tri.len() != 3 { + continue; + } + let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); + let pa = Vec3::from_array(positions[a]); + let pb = Vec3::from_array(positions[b]); + let pc = Vec3::from_array(positions[c]); + let fn_ = (pb - pa).cross(pc - pa); + for idx in [a, b, c] { + let n = &mut normals[idx]; + n[0] += fn_.x; + n[1] += fn_.y; + n[2] += fn_.z; + } + } + for n in normals.iter_mut() { + let v = Vec3::from_array(*n); + *n = v.normalize().to_array(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_simple_triangle() { + let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"; + let geo = parse_obj(content).unwrap(); + assert_eq!(geo.positions.len(), 3); + assert_eq!(geo.indices.as_ref().unwrap().len(), 3); + geo.validate().unwrap(); + } + + #[test] + fn parse_quad_splits_to_two_tris() { + let content = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n"; + let geo = parse_obj(content).unwrap(); + assert_eq!(geo.positions.len(), 4); + assert_eq!(geo.indices.as_ref().unwrap().len(), 6); + } + + #[test] + fn parse_with_normals_and_uvs() { + let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nvt 0 0\nvt 1 0\nvt 0 1\nf 1/1/1 2/2/1 3/3/1\n"; + let geo = parse_obj(content).unwrap(); + assert!(geo.normals.is_some()); + assert!(geo.uvs.is_some()); + let n = geo.normals.as_ref().unwrap(); + assert_eq!(n[0], [0.0, 0.0, 1.0]); + } + + #[test] + fn parse_no_normals_computes_them() { + let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"; + let geo = parse_obj(content).unwrap(); + let n = geo.normals.as_ref().unwrap(); + assert!((n[0][2] - 1.0).abs() < 1e-4, "expected +Z normal, got {:?}", n[0]); + } + + #[test] + fn parse_empty_fails() { + assert!(parse_obj("").is_err()); + assert!(parse_obj("# just a comment\n").is_err()); + } + + #[test] + fn parse_malformed_fails() { + assert!(parse_obj("v 1 2\nf 1 2 3\n").is_err()); + assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err()); + } + + #[test] + fn parse_shared_vertex_dedup() { + let content = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3\nf 1 3 4\n"; + let geo = parse_obj(content).unwrap(); + assert_eq!(geo.positions.len(), 4); + assert_eq!(geo.indices.as_ref().unwrap().len(), 6); + } +} diff --git a/lib/src/mesh/mod.rs b/lib/src/mesh/mod.rs new file mode 100644 index 0000000..389b59e --- /dev/null +++ b/lib/src/mesh/mod.rs @@ -0,0 +1,58 @@ +//! # Mesh module — geometry sources for WSG +//! +//! This module is the single entry point for **where geometry data comes from**: +//! +//! - **`primitives`** — procedural generators (cube, sphere, torus, …), each behind a +//! feature flag so you only compile what you need. +//! - **`import`** — file loaders (OBJ, glTF), each behind a feature flag. +//! +//! All sources produce a [`Geometry`] (CPU-side vertex data: positions, normals, UVs, +//! indices). Turning that into a GPU renderable is the job of [`crate::scene::Scene::add_mesh`]. +//! +//! ## Usage +//! +//! ```rust +//! use wsg_lib::mesh::cube; +//! +//! // Procedural (feature "prim-cube") +//! let geom = cube(2.0); +//! assert_eq!(geom.positions.len(), 24); +//! ``` +//! +//! ## Features +//! +//! | Feature | Provides | +//! |---------|----------| +//! | `prim-cube` | `cube(size)` | +//! | `prim-sphere` | `uv_sphere(…)`, `icosphere(…)` | +//! | `prim-cylinder` | `cylinder(…)` | +//! | `prim-cone` | `cone(…)` | +//! | `prim-torus` | `torus(…)` | +//! | `prim-plane` | `plane(…)` | +//! | `all-prims` | all of the above | +//! | `import-obj` | `load_obj(path)` | +//! | `import-gltf` | `load_gltf(path)` | + +pub mod primitives; + +#[cfg(feature = "import-obj")] +pub mod import; + +// Flat re-exports at the `wsg::mesh` level for convenience. +#[cfg(feature = "prim-cube")] +pub use primitives::cube; +#[cfg(feature = "prim-plane")] +pub use primitives::plane; +#[cfg(feature = "prim-sphere")] +pub use primitives::{icosphere, uv_sphere}; +#[cfg(feature = "prim-cylinder")] +pub use primitives::cylinder; +#[cfg(feature = "prim-cone")] +pub use primitives::cone; +#[cfg(feature = "prim-torus")] +pub use primitives::torus; + +#[cfg(feature = "import-obj")] +pub use import::load_obj; +#[cfg(feature = "import-gltf")] +pub use import::load_gltf; diff --git a/lib/src/mesh/primitives/cone.rs b/lib/src/mesh/primitives/cone.rs new file mode 100644 index 0000000..a1f83fb --- /dev/null +++ b/lib/src/mesh/primitives/cone.rs @@ -0,0 +1,89 @@ +//! Cone primitive — side (apex + base ring) + base cap. + +use crate::core::geometry::Geometry; +use glam::Vec3; + +/// Generates a cone of radius `radius` and height `height` (apex at +h/2, base at -h/2), closed by a +/// base, with `sectors` segments. Analytical side normals (tilted outward); +/// base normal −Y. +pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry { + let si = sectors.max(3); + let h = height * 0.5; + let (mut positions, mut normals, mut uvs, mut indices) = ( + Vec::<[f32; 3]>::new(), + Vec::<[f32; 3]>::new(), + Vec::<[f32; 2]>::new(), + Vec::::new(), + ); + + // Side elements: apex + base ring. + let apex = 0u16; + positions.push([0.0, h, 0.0]); + normals.push([0.0, 1.0, 0.0]); // shared apex; normal close to +Y by default + uvs.push([0.5, 1.0]); + let base_start = 1u16; + for s in 0..=si { + let u = s as f32 / si as f32; + let theta = u * 2.0 * std::f32::consts::PI; + let (sin_t, cos_t) = theta.sin_cos(); + positions.push([radius * cos_t, -h, radius * sin_t]); + // Side normal: normalize(h·cosθ, r, h·sinθ). + let n = Vec3::new(h * cos_t, radius, h * sin_t).normalize(); + normals.push(n.to_array()); + uvs.push([u, 0.0]); + } + for s in 0..si { + indices.extend_from_slice(&[apex, base_start + s as u16 + 1, base_start + s as u16]); + } + + // Closed base (circle at -h/2, normal -Y). + let center = positions.len() as u16; + positions.push([0.0, -h, 0.0]); + normals.push([0.0, -1.0, 0.0]); + uvs.push([0.5, 0.5]); + let ring = positions.len() as u16; + for s in 0..=si { + let u = s as f32 / si as f32; + let theta = u * 2.0 * std::f32::consts::PI; + let (sin_t, cos_t) = theta.sin_cos(); + positions.push([radius * cos_t, -h, radius * sin_t]); + normals.push([0.0, -1.0, 0.0]); + uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]); + } + for s in 0..si { + let r = ring + s as u16; + indices.extend_from_slice(&[center, r, r + 1]); + } + + Geometry::new(positions) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_valid(geo: &Geometry) { + geo.validate().expect("generated geometry must validate"); + let positions = &geo.positions; + let normals = geo.normals.as_ref().expect("normals present"); + let uvs = geo.uvs.as_ref().expect("uvs present"); + let indices = geo.indices.as_ref().expect("indices present"); + assert_eq!(normals.len(), positions.len()); + assert_eq!(uvs.len(), positions.len()); + for n in normals { + let len = Vec3::from_array(*n).length(); + assert!((len - 1.0).abs() < 1e-3); + } + for &i in indices { + assert!((i as usize) < positions.len()); + } + } + + #[test] + fn cone_validate() { + assert_valid(&cone(0.5, 1.0, 16)); + } +} diff --git a/lib/src/mesh/primitives/cube.rs b/lib/src/mesh/primitives/cube.rs new file mode 100644 index 0000000..dd1ffdb --- /dev/null +++ b/lib/src/mesh/primitives/cube.rs @@ -0,0 +1,97 @@ +//! Cube primitive — 24 vertices (4 per face) + 36 indices. + +use crate::core::geometry::Geometry; + +/// Generates a cube centered at the origin, edge `size`, with one normal and UVs per face. +/// 24 vertices (4 per face) + 36 indices. +pub fn cube(size: f32) -> Geometry { + let s = size * 0.5; + let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ + ( + [0.0, 0.0, 1.0], + [[-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]], + ), + ( + [0.0, 0.0, -1.0], + [[s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]], + ), + ( + [1.0, 0.0, 0.0], + [[s, -s, -s], [s, s, -s], [s, s, s], [s, -s, s]], + ), + ( + [-1.0, 0.0, 0.0], + [[-s, -s, s], [-s, s, s], [-s, s, -s], [-s, -s, -s]], + ), + ( + [0.0, 1.0, 0.0], + [[-s, s, -s], [s, s, -s], [s, s, s], [-s, s, s]], + ), + ( + [0.0, -1.0, 0.0], + [[-s, -s, s], [s, -s, s], [s, -s, -s], [-s, -s, -s]], + ), + ]; + + let mut positions = Vec::with_capacity(24); + let mut normals = Vec::with_capacity(24); + let mut uvs = Vec::with_capacity(24); + let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + for (normal, corners) in faces { + for (i, corner) in corners.iter().enumerate() { + positions.push(*corner); + normals.push(normal); + uvs.push(quad_uvs[i]); + } + } + + let mut indices = Vec::with_capacity(36); + for face in 0..6u16 { + let b = face * 4; + indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]); + } + + Geometry::new(positions) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +#[cfg(test)] +mod tests { + use super::*; + use glam::Vec3; + + fn assert_valid(geo: &Geometry) { + geo.validate().expect("generated geometry must validate"); + let positions = &geo.positions; + let normals = geo.normals.as_ref().expect("normals present"); + let uvs = geo.uvs.as_ref().expect("uvs present"); + let indices = geo.indices.as_ref().expect("indices present"); + assert_eq!(normals.len(), positions.len(), "normals/positions count"); + assert_eq!(uvs.len(), positions.len(), "uvs/positions count"); + for n in normals { + let len = Vec3::from_array(*n).length(); + assert!((len - 1.0).abs() < 1e-3, "unit normal, got {len}"); + } + for &i in indices { + assert!((i as usize) < positions.len(), "index {i} in bounds"); + } + } + + #[test] + fn cube_counts() { + let g = cube(1.0); + assert_eq!(g.positions.len(), 24); + assert_eq!(g.indices.as_ref().unwrap().len(), 36); + assert_valid(&g); + let g2 = cube(2.0); + assert_eq!( + g2.positions, + g.positions + .iter() + .map(|p| [p[0] * 2.0, p[1] * 2.0, p[2] * 2.0]) + .collect::>() + ); + } +} diff --git a/lib/src/mesh/primitives/cylinder.rs b/lib/src/mesh/primitives/cylinder.rs new file mode 100644 index 0000000..10a129f --- /dev/null +++ b/lib/src/mesh/primitives/cylinder.rs @@ -0,0 +1,94 @@ +//! Cylinder primitive — side + top/bottom caps. + +use crate::core::geometry::Geometry; +use glam::Vec3; + +/// Generates a cylinder of radius `radius` and height `height` (along Y, centered), with +/// `sectors` segments. Parts: side (smooth radial normals), top cap (+Y), bottom base (−Y). +pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry { + let si = sectors.max(3); + let h = height * 0.5; + let (mut positions, mut normals, mut uvs, mut indices) = ( + Vec::<[f32; 3]>::new(), + Vec::<[f32; 3]>::new(), + Vec::<[f32; 2]>::new(), + Vec::::new(), + ); + + // Side: radial columns × 2 rows (bottom/top). + let side_base = 0u16; + for row in 0..=1 { + let y = if row == 0 { -h } else { h }; + for s in 0..=si { + let u = s as f32 / si as f32; + let theta = u * 2.0 * std::f32::consts::PI; + let (sin_t, cos_t) = theta.sin_cos(); + let radial = Vec3::new(cos_t, 0.0, sin_t); + positions.push((radial * radius + Vec3::new(0.0, y, 0.0)).to_array()); + normals.push(radial.to_array()); + uvs.push([u, row as f32]); + } + } + for s in 0..si { + let a = side_base + s as u16; + let b = a + 1; + let c = side_base + (si as u16) + 1 + s as u16; + let d = c + 1; + indices.extend_from_slice(&[a, c, b, b, c, d]); + } + + // Caps: center + ring at each end. + for (y, normal) in [(h, [0.0, 1.0, 0.0]), (-h, [0.0, -1.0, 0.0])] { + let center = positions.len() as u16; + positions.push([0.0, y, 0.0]); + normals.push(normal); + uvs.push([0.5, 0.5]); + let ring_start = positions.len() as u16; + for s in 0..=si { + let u = s as f32 / si as f32; + let theta = u * 2.0 * std::f32::consts::PI; + let (sin_t, cos_t) = theta.sin_cos(); + positions.push([radius * cos_t, y, radius * sin_t]); + normals.push(normal); + uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]); + } + for s in 0..si { + let a = ring_start + s as u16; + indices.extend_from_slice(&[center, a + 1, a]); + } + } + + Geometry::new(positions) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_valid(geo: &Geometry) { + geo.validate().expect("generated geometry must validate"); + let positions = &geo.positions; + let normals = geo.normals.as_ref().expect("normals present"); + let uvs = geo.uvs.as_ref().expect("uvs present"); + let indices = geo.indices.as_ref().expect("indices present"); + assert_eq!(normals.len(), positions.len()); + assert_eq!(uvs.len(), positions.len()); + for n in normals { + let len = Vec3::from_array(*n).length(); + assert!((len - 1.0).abs() < 1e-3); + } + for &i in indices { + assert!((i as usize) < positions.len()); + } + } + + #[test] + fn cylinder_validate() { + assert_valid(&cylinder(0.5, 1.0, 16)); + let c = cylinder(0.5, 1.0, 8); + assert!(c.positions.iter().all(|p| p[1].abs() <= 0.5 + 1e-5)); + } +} diff --git a/lib/src/mesh/primitives/mod.rs b/lib/src/mesh/primitives/mod.rs new file mode 100644 index 0000000..753322d --- /dev/null +++ b/lib/src/mesh/primitives/mod.rs @@ -0,0 +1,33 @@ +//! Procedural mesh generators — each behind a feature flag. +//! +//! Enable features in `Cargo.toml`: +//! ```toml +//! wsg = { features = ["prim-cube", "prim-sphere"] } +//! ``` + +#[cfg(feature = "prim-cube")] +pub mod cube; +#[cfg(feature = "prim-plane")] +pub mod plane; +#[cfg(feature = "prim-sphere")] +pub mod sphere; +#[cfg(feature = "prim-cylinder")] +pub mod cylinder; +#[cfg(feature = "prim-cone")] +pub mod cone; +#[cfg(feature = "prim-torus")] +pub mod torus; + +// Flat re-exports: `use wsg::mesh::primitives::cube` or `use wsg::mesh::cube` +#[cfg(feature = "prim-cube")] +pub use cube::cube; +#[cfg(feature = "prim-plane")] +pub use plane::plane; +#[cfg(feature = "prim-sphere")] +pub use sphere::{icosphere, uv_sphere}; +#[cfg(feature = "prim-cylinder")] +pub use cylinder::cylinder; +#[cfg(feature = "prim-cone")] +pub use cone::cone; +#[cfg(feature = "prim-torus")] +pub use torus::torus; diff --git a/lib/src/mesh/primitives/plane.rs b/lib/src/mesh/primitives/plane.rs new file mode 100644 index 0000000..bb2bdd9 --- /dev/null +++ b/lib/src/mesh/primitives/plane.rs @@ -0,0 +1,74 @@ +//! Plane primitive — horizontal plane in XZ with subdivisions. + +use crate::core::geometry::Geometry; + +/// Generates a horizontal plane in the XZ plane (normal +Y), centered at (0, 0, 0), with +/// `width` × `depth` dimensions, subdivided into `seg_x` × `seg_z` cells. +pub fn plane(width: f32, depth: f32, seg_x: u32, seg_z: u32) -> Geometry { + let sx = seg_x.max(1); + let sz = seg_z.max(1); + let (mut positions, mut normals, mut uvs, mut indices) = ( + Vec::<[f32; 3]>::new(), + Vec::<[f32; 3]>::new(), + Vec::<[f32; 2]>::new(), + Vec::::new(), + ); + for z in 0..=sz { + let vz = z as f32 / sz as f32; + for x in 0..=sx { + let vx = x as f32 / sx as f32; + positions.push([(vx - 0.5) * width, 0.0, (vz - 0.5) * depth]); + normals.push([0.0, 1.0, 0.0]); + uvs.push([vx, vz]); + } + } + for z in 0..sz { + for x in 0..sx { + let a = z * (sx + 1) + x; + let b = a + 1; + let c = (z + 1) * (sx + 1) + x; + let d = c + 1; + indices + .extend_from_slice(&[a as u16, c as u16, b as u16, b as u16, c as u16, d as u16]); + } + } + Geometry::new(positions) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +#[cfg(test)] +mod tests { + use super::*; + use glam::Vec3; + + fn assert_valid(geo: &Geometry) { + geo.validate().expect("generated geometry must validate"); + let positions = &geo.positions; + let normals = geo.normals.as_ref().expect("normals present"); + let uvs = geo.uvs.as_ref().expect("uvs present"); + let indices = geo.indices.as_ref().expect("indices present"); + assert_eq!(normals.len(), positions.len()); + assert_eq!(uvs.len(), positions.len()); + for n in normals { + let len = Vec3::from_array(*n).length(); + assert!((len - 1.0).abs() < 1e-3); + } + for &i in indices { + assert!((i as usize) < positions.len()); + } + } + + #[test] + fn plane_counts() { + let g = plane(2.0, 3.0, 1, 1); + assert_eq!(g.positions.len(), 4); + assert_eq!(g.indices.as_ref().unwrap().len(), 6); + assert_valid(&g); + assert!(g.positions.iter().all(|p| p[1] == 0.0)); + let g2 = plane(2.0, 3.0, 4, 5); + assert_eq!(g2.positions.len(), (4 + 1) * (5 + 1)); + assert_valid(&g2); + } +} diff --git a/lib/src/mesh/primitives/sphere.rs b/lib/src/mesh/primitives/sphere.rs new file mode 100644 index 0000000..c48ab04 --- /dev/null +++ b/lib/src/mesh/primitives/sphere.rs @@ -0,0 +1,176 @@ +//! Sphere primitives — UV sphere (lat/long) + icosphere (subdivided icosahedron). + +use crate::core::geometry::Geometry; +use glam::Vec3; +use std::collections::HashMap; + +/// Generates a UV (latitude/longitude) sphere of radius `radius`, with `sectors` segments around +/// and `stacks` vertical rings. Smooth normals = normalized position; spherical UVs. +pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Geometry { + let si = sectors.max(3); + let st = stacks.max(3); + let (mut positions, mut normals, mut uvs, mut indices) = ( + Vec::<[f32; 3]>::new(), + Vec::<[f32; 3]>::new(), + Vec::<[f32; 2]>::new(), + Vec::::new(), + ); + for stack in 0..=st { + let v = stack as f32 / st as f32; + let phi = v * std::f32::consts::PI; + for sector in 0..=si { + let u = sector as f32 / si as f32; + let theta = u * 2.0 * std::f32::consts::PI; + let (sin_p, cos_p) = phi.sin_cos(); + let (sin_t, cos_t) = theta.sin_cos(); + let pos = Vec3::new( + radius * sin_p * cos_t, + radius * cos_p, + radius * sin_p * sin_t, + ); + positions.push(pos.to_array()); + normals.push(pos.normalize().to_array()); + uvs.push([u, v]); + } + } + for stack in 0..st { + for sector in 0..si { + let k1 = stack * (si + 1) + sector; + let k2 = k1 + si + 1; + let (k1, k2) = (k1 as u16, k2 as u16); + indices.extend_from_slice(&[k1, k2, k1 + 1, k1 + 1, k2, k2 + 1]); + } + } + Geometry::new(positions) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +/// Generates an icosphere (subdivided icosahedron) of radius `radius`. +/// `subdivisions = 0` gives an icosahedron (12 verts / 20 faces); each subdivision refines ×4. +pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry { + let t = (1.0 + 5.0_f32.sqrt()) * 0.5; + let mut positions: Vec = [ + [-1.0, t, 0.0], [1.0, t, 0.0], [-1.0, -t, 0.0], [1.0, -t, 0.0], + [0.0, -1.0, t], [0.0, 1.0, t], [0.0, -1.0, -t], [0.0, 1.0, -t], + [t, 0.0, -1.0], [t, 0.0, 1.0], [-t, 0.0, -1.0], [-t, 0.0, 1.0], + ] + .iter() + .map(|v| Vec3::from_array(*v).normalize()) + .collect(); + + let mut faces: Vec<[u32; 3]> = [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ] + .into_iter() + .collect(); + + for _ in 0..subdivisions { + let mut midpoint = HashMap::new(); + let old_faces = std::mem::take(&mut faces); + for [a, b, c] in old_faces { + let ab = subdiv_midpoint(&mut positions, &mut midpoint, a, b); + let bc = subdiv_midpoint(&mut positions, &mut midpoint, b, c); + let ca = subdiv_midpoint(&mut positions, &mut midpoint, c, a); + faces.push([a, ab, ca]); + faces.push([ab, b, bc]); + faces.push([ca, bc, c]); + faces.push([ab, bc, ca]); + } + } + + let mut normals = Vec::with_capacity(positions.len()); + let mut uvs = Vec::with_capacity(positions.len()); + for p in &positions { + let dir = p.normalize(); + normals.push(dir.to_array()); + uvs.push(spherical_uv(dir)); + } + let scaled: Vec<[f32; 3]> = positions.iter().map(|p| (*p * radius).to_array()).collect(); + + let mut indices = Vec::with_capacity(faces.len() * 3); + for [a, b, c] in &faces { + indices.extend_from_slice(&[*a as u16, *b as u16, *c as u16]); + } + Geometry::new(scaled) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +fn subdiv_midpoint( + positions: &mut Vec, + cache: &mut HashMap<(u32, u32), u32>, + a: u32, + b: u32, +) -> u32 { + let key = if a < b { (a, b) } else { (b, a) }; + if let Some(&i) = cache.get(&key) { + return i; + } + let mid = (positions[a as usize] + positions[b as usize]).normalize(); + positions.push(mid); + let i = (positions.len() - 1) as u32; + cache.insert(key, i); + i +} + +fn spherical_uv(dir: Vec3) -> [f32; 2] { + let u = 0.5 + (dir.z.atan2(dir.x) / (2.0 * std::f32::consts::PI)); + let v = 0.5 - (dir.y.asin() / std::f32::consts::PI); + [u, v] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_valid(geo: &Geometry) { + geo.validate().expect("generated geometry must validate"); + let positions = &geo.positions; + let normals = geo.normals.as_ref().expect("normals present"); + let uvs = geo.uvs.as_ref().expect("uvs present"); + let indices = geo.indices.as_ref().expect("indices present"); + assert_eq!(normals.len(), positions.len()); + assert_eq!(uvs.len(), positions.len()); + for n in normals { + let len = Vec3::from_array(*n).length(); + assert!((len - 1.0).abs() < 1e-3); + } + for &i in indices { + assert!((i as usize) < positions.len()); + } + } + + #[test] + fn uv_sphere_counts_and_normals() { + let g = uv_sphere(1.0, 12, 8); + assert_eq!(g.positions.len(), (12 + 1) * (8 + 1)); + assert_valid(&g); + for (p, n) in g.positions.iter().zip(g.normals.as_ref().unwrap()) { + let diff = (Vec3::from_array(*p) / 1.0 - Vec3::from_array(*n)).length(); + assert!(diff < 1e-4); + } + } + + #[test] + fn icosphere_grows_with_subdivision() { + let base = icosphere(1.0, 0); + assert_eq!(base.positions.len(), 12); + assert_eq!(base.indices.as_ref().unwrap().len(), 60); + assert_valid(&base); + let once = icosphere(1.0, 1); + assert!(once.positions.len() > base.positions.len()); + assert_valid(&once); + for (p, n) in once.positions.iter().zip(once.normals.as_ref().unwrap()) { + let r = Vec3::from_array(*p).length(); + assert!((r - 1.0).abs() < 1e-3); + let diff = (Vec3::from_array(*p).normalize() - Vec3::from_array(*n)).length(); + assert!(diff < 1e-4); + } + } +} diff --git a/lib/src/mesh/primitives/torus.rs b/lib/src/mesh/primitives/torus.rs new file mode 100644 index 0000000..0d9469c --- /dev/null +++ b/lib/src/mesh/primitives/torus.rs @@ -0,0 +1,79 @@ +//! Torus primitive — tube around a ring. + +use crate::core::geometry::Geometry; +use glam::Vec3; + +/// Generates a torus with major radius `major`, minor radius `minor`, with `major_segments` +/// segments around the ring and `minor_segments` around the tube cross-section. +pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -> Geometry { + let mj = major_segments.max(3); + let mn = minor_segments.max(3); + let (mut positions, mut normals, mut uvs, mut indices) = ( + Vec::<[f32; 3]>::new(), + Vec::<[f32; 3]>::new(), + Vec::<[f32; 2]>::new(), + Vec::::new(), + ); + for i in 0..=mj { + let u = i as f32 / mj as f32; + let ua = u * 2.0 * std::f32::consts::PI; + let (sin_u, cos_u) = ua.sin_cos(); + for j in 0..=mn { + let v = j as f32 / mn as f32; + let va = v * 2.0 * std::f32::consts::PI; + let (sin_v, cos_v) = va.sin_cos(); + let ring = Vec3::new( + (major + minor * cos_v) * cos_u, + minor * sin_v, + (major + minor * cos_v) * sin_u, + ); + positions.push(ring.to_array()); + let n = Vec3::new(cos_v * cos_u, sin_v, cos_v * sin_u).normalize(); + normals.push(n.to_array()); + uvs.push([u, v]); + } + } + for i in 0..mj { + for j in 0..mn { + let a = i * (mn + 1) + j; + let b = a + 1; + let c = a + mn + 1; + let d = c + 1; + indices + .extend_from_slice(&[a as u16, b as u16, c as u16, b as u16, d as u16, c as u16]); + } + } + Geometry::new(positions) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_valid(geo: &Geometry) { + geo.validate().expect("generated geometry must validate"); + let positions = &geo.positions; + let normals = geo.normals.as_ref().expect("normals present"); + let uvs = geo.uvs.as_ref().expect("uvs present"); + let indices = geo.indices.as_ref().expect("indices present"); + assert_eq!(normals.len(), positions.len()); + assert_eq!(uvs.len(), positions.len()); + for n in normals { + let len = Vec3::from_array(*n).length(); + assert!((len - 1.0).abs() < 1e-3); + } + for &i in indices { + assert!((i as usize) < positions.len()); + } + } + + #[test] + fn torus_validate() { + let g = torus(1.0, 0.25, 24, 12); + assert_valid(&g); + assert_eq!(g.positions.len(), (24 + 1) * (12 + 1)); + } +} diff --git a/lib/src/prelude.rs b/lib/src/prelude.rs new file mode 100644 index 0000000..5caaa16 --- /dev/null +++ b/lib/src/prelude.rs @@ -0,0 +1,46 @@ +//! # WSG Prelude +//! +//! Re-exports the most commonly used types in a single glob import: +//! +//! ```rust +//! use wsg_lib::prelude::*; +//! +//! let geom = cube(2.0); +//! assert_eq!(geom.positions.len(), 24); +//! let tf = Transform::identity(); +//! ``` +//! +//! This avoids long import paths for the types you touch every day. + +// Core types +pub use crate::core::geometry::{BBox, Geometry}; +pub use crate::core::transform::Transform; +pub use crate::core::{ShadowConfig, ToneMapper}; + +// App / handler (already at crate root, re-exported here for convenience) +pub use crate::app::AppBuilder; +pub use crate::handler::AppHandler; + +// Primitives (available when the corresponding feature is enabled) +#[cfg(feature = "prim-cube")] +pub use crate::mesh::cube; +#[cfg(feature = "prim-sphere")] +pub use crate::mesh::{icosphere, uv_sphere}; +#[cfg(feature = "prim-cylinder")] +pub use crate::mesh::cylinder; +#[cfg(feature = "prim-cone")] +pub use crate::mesh::cone; +#[cfg(feature = "prim-torus")] +pub use crate::mesh::torus; +#[cfg(feature = "prim-plane")] +pub use crate::mesh::plane; + +// Import (available when the corresponding feature is enabled) +#[cfg(feature = "import-obj")] +pub use crate::mesh::load_obj; +#[cfg(feature = "import-gltf")] +pub use crate::mesh::load_gltf; + +// Import error type +#[cfg(any(feature = "import-obj", feature = "import-gltf"))] +pub use crate::mesh::import::MeshImportError; diff --git a/lib/src/resources/mesh.rs b/lib/src/resources/mesh.rs index 69e06c0..8ecc2dd 100644 --- a/lib/src/resources/mesh.rs +++ b/lib/src/resources/mesh.rs @@ -25,7 +25,7 @@ //! (which took raw `&[Vertex]`) were removed in Step 8: the `Scene` declares meshes from a `Geometry`, and //! `Mesh` derives its interleaved vertices internally via `Geometry::to_vertices()`. -use crate::math::Geometry; +use crate::core::Geometry; use crate::resources::Material; use crate::resources::Vertex; use crate::resources::uniform::LodRow; @@ -272,7 +272,7 @@ impl Mesh { #[cfg(test)] mod tests { use super::*; - use crate::math::primitives; + use crate::mesh::primitives; #[test] fn pack_levels_offsets_and_rebasing() { diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index e90803d..a1f8c4a 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -36,4 +36,4 @@ pub use vertex::Vertex; // Convenience re-export of `math::Geometry` (Step 8, D2) so examples can build meshes // from `wsg_lib::resources::Geometry` without importing `math` separately. -pub use crate::math::Geometry; +pub use crate::core::Geometry; diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index 14513dd..3c16658 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -225,10 +225,10 @@ pub struct TransformSlot { } impl TransformSlot { - /// Builds an active transform slot from a CPU [`crate::math::Transform`] + the mesh's draw + /// Builds an active transform slot from a CPU [`crate::core::Transform`] + the mesh's draw /// metadata. `mesh_index` / `draw_count` are packed into `flags`; `active` is 1. pub fn from_transform( - t: &crate::math::Transform, + t: &crate::core::Transform, mesh_index: u32, draw_count: u32, has_index: bool, @@ -313,8 +313,8 @@ pub struct BBoxSlot { } impl BBoxSlot { - /// Builds a slot from a CPU [`crate::math::BBox`] (padding zeroed). - pub fn from_bbox(b: &crate::math::BBox) -> Self { + /// Builds a slot from a CPU [`crate::core::BBox`] (padding zeroed). + pub fn from_bbox(b: &crate::core::BBox) -> Self { Self { min: b.min, max: b.max, @@ -377,8 +377,8 @@ impl CullUniforms { } } - /// Builds the cull block directly from a [`crate::math::Frustum`] (its six unit planes). - pub fn from_frustum(f: &crate::math::Frustum, num_slots: u32, culling: bool) -> Self { + /// Builds the cull block directly from a [`crate::core::Frustum`] (its six unit planes). + pub fn from_frustum(f: &crate::core::Frustum, num_slots: u32, culling: bool) -> Self { Self::new(f.planes, num_slots, culling) } } diff --git a/lib/src/scene/entity.rs b/lib/src/scene/entity.rs index fcf6645..5340915 100644 --- a/lib/src/scene/entity.rs +++ b/lib/src/scene/entity.rs @@ -11,7 +11,7 @@ //! matrix during rendering. //! - `resources::Mesh` is the referenced render resource, resolved by `Scene`; its Material is read by the Renderer. -use crate::math::Transform; +use crate::core::Transform; /// A renderable entity: a mesh (with its own material) and a world-space transform. /// diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index e09ad97..ff6bae5 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -15,7 +15,7 @@ //! instead of `App`. It can therefore build materials and meshes itself (`add_material_shader`, `create_mesh`) and inject //! a default material for meshes that carry none (`default_material`). -use crate::math::{Geometry, Transform}; +use crate::core::{Geometry, Transform}; use crate::pipeline::PipelineCache; use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot}; use crate::scene::Entity;