pyforge3d 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/PKG-INFO +3 -3
  2. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/README.md +1 -1
  3. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/pyproject.toml +5 -3
  4. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/__init__.py +4 -3
  5. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/ik_fabrik.py +2 -1
  6. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/system.py +1 -3
  7. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/app.py +43 -25
  8. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/clip.py +1 -1
  9. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/camera.py +181 -0
  10. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/character.py +124 -4
  11. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/detection.py +104 -11
  12. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/gjk.py +12 -1
  13. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/heightfield.py +91 -0
  14. pyforge3d-2.2.0/src/forge3d/ecs/component.py +102 -0
  15. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/entity.py +2 -2
  16. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/system.py +14 -2
  17. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/facade.py +546 -40
  18. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/input.py +146 -7
  19. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/emitter.py +3 -2
  20. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/meshes.py +190 -0
  21. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/shaders.py +17 -10
  22. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/window_renderer.py +279 -56
  23. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/node.py +2 -2
  24. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/world.py +8 -0
  25. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/panels.py +2 -4
  26. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/system.py +1 -1
  27. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/viewer.py +99 -10
  28. pyforge3d-2.1.0/src/forge3d/ecs/component.py +0 -56
  29. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/Cargo.lock +0 -0
  30. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/Cargo.toml +0 -0
  31. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/LICENSE +0 -0
  32. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/_core.pyi +0 -0
  33. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/__init__.py +0 -0
  34. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/clip.py +0 -0
  35. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/player.py +0 -0
  36. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/skeleton.py +0 -0
  37. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/__init__.py +0 -0
  38. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/null_driver.py +0 -0
  39. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/openal_driver.py +0 -0
  40. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/source.py +0 -0
  41. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/system.py +0 -0
  42. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/backend.py +0 -0
  43. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/__init__.py +0 -0
  44. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/epa.py +0 -0
  45. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/layers.py +0 -0
  46. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/raycast.py +0 -0
  47. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/__init__.py +0 -0
  48. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/base.py +0 -0
  49. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/joint_type.py +0 -0
  50. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/joints.py +0 -0
  51. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/contact/__init__.py +0 -0
  52. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/contact/solver.py +0 -0
  53. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/__init__.py +0 -0
  54. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/aba.py +0 -0
  55. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/crba.py +0 -0
  56. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/model.py +0 -0
  57. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/rnea.py +0 -0
  58. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/__init__.py +0 -0
  59. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/bridge.py +0 -0
  60. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/serialization.py +0 -0
  61. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/transform.py +0 -0
  62. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/__init__.py +0 -0
  63. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/editor_app.py +0 -0
  64. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/gizmo.py +0 -0
  65. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/layout.py +0 -0
  66. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/errors.py +0 -0
  67. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/events.py +0 -0
  68. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/__init__.py +0 -0
  69. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/mesh_data.py +0 -0
  70. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/obj_loader.py +0 -0
  71. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/world_snapshot.py +0 -0
  72. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/logging.py +0 -0
  73. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/__init__.py +0 -0
  74. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/inertia.py +0 -0
  75. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/quaternion.py +0 -0
  76. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/se3.py +0 -0
  77. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/spatial.py +0 -0
  78. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/__init__.py +0 -0
  79. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/kinematics.py +0 -0
  80. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/robot_config.py +0 -0
  81. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/urdf_loader.py +0 -0
  82. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/__init__.py +0 -0
  83. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/presets.py +0 -0
  84. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/system.py +0 -0
  85. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/profiler.py +0 -0
  86. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/py.typed +0 -0
  87. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/recorder.py +0 -0
  88. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/__init__.py +0 -0
  89. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/base.py +0 -0
  90. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/deferred/__init__.py +0 -0
  91. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/deferred/renderer.py +0 -0
  92. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/__init__.py +0 -0
  93. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/raytracer.py +0 -0
  94. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/renderer.py +0 -0
  95. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/scene.py +0 -0
  96. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/passes/__init__.py +0 -0
  97. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/passes/base.py +0 -0
  98. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/__init__.py +0 -0
  99. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/context.py +0 -0
  100. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/renderer.py +0 -0
  101. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_down.frag +0 -0
  102. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_up.frag +0 -0
  103. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/fullscreen.vert +0 -0
  104. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.frag +0 -0
  105. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.vert +0 -0
  106. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/lighting.frag +0 -0
  107. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/pbr.wgsl +0 -0
  108. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.frag +0 -0
  109. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.vert +0 -0
  110. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao.frag +0 -0
  111. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao_blur.frag +0 -0
  112. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/tonemap.frag +0 -0
  113. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/update_particles.comp +0 -0
  114. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/snapshot.py +0 -0
  115. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/__init__.py +0 -0
  116. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/pipeline.py +0 -0
  117. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/renderer.py +0 -0
  118. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/robot/__init__.py +0 -0
  119. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/robot/presets.py +0 -0
  120. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/robot/robot.py +0 -0
  121. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/__init__.py +0 -0
  122. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/manager.py +0 -0
  123. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/prefab.py +0 -0
  124. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/__init__.py +0 -0
  125. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/domain_rand.py +0 -0
  126. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/jax_batch.py +0 -0
  127. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/__init__.py +0 -0
  128. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/backend.py +0 -0
  129. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/canvas.py +0 -0
  130. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/Cargo.toml +0 -0
  131. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/benches/physics_bench.rs +0 -0
  132. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/bvh.rs +0 -0
  133. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/gjk_epa.rs +0 -0
  134. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/lib.rs +0 -0
  135. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/math_simd.rs +0 -0
  136. {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/pgs_solver.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyforge3d
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Intended Audience :: Developers
@@ -46,7 +46,7 @@ Provides-Extra: docs
46
46
  Provides-Extra: render
47
47
  Provides-Extra: rl
48
48
  License-File: LICENSE
49
- Summary: Pure-Python 3D physics game engine — fast like native, beautiful like simulation.
49
+ Summary: Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.
50
50
  Keywords: game-engine,physics,simulation,3d,rigid-body,collision,rendering,robotics,reinforcement-learning,numpy,jax
51
51
  Author-email: iruki <me@iruki.dev>
52
52
  Requires-Python: >=3.12
@@ -59,7 +59,7 @@ Project-URL: Repository, https://github.com/iruki-dev/forge3d
59
59
 
60
60
  # forge3d
61
61
 
62
- > **Pure-Python 3D game engine — easy like pygame, beautiful like simulation.**
62
+ > **Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.**
63
63
 
64
64
  [![PyPI version](https://img.shields.io/pypi/v/pyforge3d.svg)](https://pypi.org/project/pyforge3d/)
65
65
  [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/)
@@ -1,6 +1,6 @@
1
1
  # forge3d
2
2
 
3
- > **Pure-Python 3D game engine — easy like pygame, beautiful like simulation.**
3
+ > **Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.**
4
4
 
5
5
  [![PyPI version](https://img.shields.io/pypi/v/pyforge3d.svg)](https://pypi.org/project/pyforge3d/)
6
6
  [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/)
@@ -6,8 +6,8 @@ build-backend = "maturin"
6
6
 
7
7
  [project]
8
8
  name = "pyforge3d"
9
- version = "2.1.0"
10
- description = "Pure-Python 3D physics game engine — fast like native, beautiful like simulation."
9
+ version = "2.2.0"
10
+ description = "Pure-Python 3D physics game engine — own dynamics, own rules, no compromises."
11
11
  readme = "README.md"
12
12
  license = { file = "LICENSE" }
13
13
  requires-python = ">=3.12"
@@ -103,13 +103,15 @@ addopts = "-q --tb=short"
103
103
  line-length = 100
104
104
  target-version = "py312"
105
105
  src = ["src"]
106
+ exclude = ["archive", "demos"]
106
107
 
107
108
  [tool.ruff.lint]
108
109
  select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
109
110
  ignore = ["B008", "SIM118"]
110
111
 
111
112
  [tool.ruff.lint.per-file-ignores]
112
- "tests/**" = ["E501"]
113
+ "tests/**" = ["E501"]
114
+ "apps/**" = ["E501", "E701", "E702", "B905"]
113
115
 
114
116
  # ── Type-check ────────────────────────────────────────────────────────────────
115
117
 
@@ -1,6 +1,6 @@
1
1
  """forge3d — pure-Python 3D game engine.
2
2
 
3
- "Fast like native, beautiful like simulation."
3
+ "Pure-Python 3D physics game engine — own dynamics, own rules, no compromises."
4
4
  Coordinate system: z-up, SI units (metres, kg, seconds).
5
5
 
6
6
  Minimal example (14 lines)::
@@ -103,7 +103,7 @@ from forge3d.editor import EditorApp, PlayState
103
103
  from forge3d.errors import Forge3dError, PhysicsError, RenderError, ValidationError
104
104
  from forge3d.events import CollisionEvent, CollisionHandler
105
105
  from forge3d.facade import Body, Material, Shape, World
106
- from forge3d.input import Input, InputBuilder, Key
106
+ from forge3d.input import Input, InputBuilder, Key, ScriptedInput
107
107
  from forge3d.io.world_snapshot import StateRecorder
108
108
 
109
109
  # Particle system (P31)
@@ -121,7 +121,7 @@ from forge3d.scene import Prefab, SceneManager, SceneNode
121
121
  from forge3d.ui import Canvas, DebugPanel, HierarchyPanel, InspectorPanel, UISystem
122
122
  from forge3d.viewer import Viewer
123
123
 
124
- __version__ = "2.1.0"
124
+ __version__ = "2.1.1"
125
125
 
126
126
  # ── API 안정성 선언 ───────────────────────────────────────────────────────────
127
127
  # Stable (v3까지 Breaking change 없음):
@@ -158,6 +158,7 @@ __all__ = [
158
158
  "Input",
159
159
  "InputBuilder",
160
160
  "Key",
161
+ "ScriptedInput",
161
162
  # Camera
162
163
  "OrbitCamera",
163
164
  "FollowCamera",
@@ -1,6 +1,7 @@
1
1
  """FABRIK IK 솔버 — Forward And Backward Reaching Inverse Kinematics.
2
2
 
3
- 참고: Aristidou & Lasenby (2011), "FABRIK: A fast, iterative solver for the Inverse Kinematics problem"
3
+ 참고: Aristidou & Lasenby (2011),
4
+ "FABRIK: A fast, iterative solver for the Inverse Kinematics problem"
4
5
  """
5
6
 
6
7
  from __future__ import annotations
@@ -33,11 +33,9 @@ class AnimationSystem(System):
33
33
 
34
34
  # AnimationPlayer가 있으면 체인 위치 읽기
35
35
  try:
36
- player = ew.get_component(e, AnimationPlayer) # type: ignore[assignment]
36
+ anim_player: AnimationPlayer = ew.get_component(e, AnimationPlayer)
37
37
  except KeyError:
38
38
  continue
39
-
40
- anim_player: AnimationPlayer = player
41
39
  joint_positions = anim_player.skeleton.joint_positions()
42
40
  if len(joint_positions) < 2:
43
41
  continue
@@ -1,6 +1,6 @@
1
1
  """forge3d App — high-level game-loop abstraction.
2
2
 
3
- Provides a decorator-driven API similar to popular game frameworks::
3
+ Provides a decorator-driven API::
4
4
 
5
5
  import forge3d as f3d
6
6
 
@@ -39,10 +39,10 @@ class App:
39
39
 
40
40
  Parameters
41
41
  ----------
42
- title : Window title (shown in windowed mode).
42
+ title : Window title.
43
43
  width : Render width in pixels.
44
44
  height : Render height in pixels.
45
- fps : Target frames per second; also the physics step rate.
45
+ fps : Physics step rate and target render rate.
46
46
  gravity : World gravity vector (x, y, z) in m/s².
47
47
 
48
48
  Examples
@@ -66,6 +66,11 @@ class App:
66
66
  height: int = 720,
67
67
  fps: float = 60.0,
68
68
  gravity: Any = (0.0, 0.0, -9.81),
69
+ substeps: int = 2,
70
+ shadow_resolution: int = 0,
71
+ sky_color: tuple | None = None,
72
+ show_grid: bool = False,
73
+ max_dt: float = 1 / 25,
69
74
  ) -> None:
70
75
  from forge3d.facade import World
71
76
 
@@ -75,6 +80,11 @@ class App:
75
80
  self._height = height
76
81
  self._fps = float(fps)
77
82
  self._dt = 1.0 / self._fps
83
+ self._substeps = substeps
84
+ self._shadow_resolution = shadow_resolution
85
+ self._sky_color = sky_color
86
+ self._show_grid = show_grid
87
+ self._max_dt = max_dt
78
88
 
79
89
  self._on_start: Callable | None = None
80
90
  self._on_update: Callable | None = None
@@ -132,52 +142,56 @@ class App:
132
142
  # ── Run ───────────────────────────────────────────────────────────────────
133
143
 
134
144
  def run(self, max_frames: int | None = None) -> None:
135
- """Start the game loop.
145
+ """Open a window and start the game loop.
136
146
 
137
- Initialises the viewer, fires :meth:`on_start`, then loops:
138
- 1. Build :class:`~forge3d.input.Input` snapshot
139
- 2. Call :meth:`on_update` with ``(world, dt, inp)``
147
+ Fires ``on_start`` once, then each frame:
148
+
149
+ 1. Read input from the OS window (keyboard / mouse).
150
+ 2. Call ``on_update(world, dt, inp)``.
140
151
  3. ``world.step(dt)``
141
152
  4. ``viewer.draw()``
142
- 5. Fire :meth:`on_render` if registered
143
- 6. Advance frame; stop when ``max_frames`` reached or window closed
153
+ 5. Call ``on_render(world, viewer)``.
154
+
155
+ The loop ends when the window is closed, ESC is pressed, or
156
+ ``max_frames`` frames have been rendered.
144
157
 
145
158
  Parameters
146
159
  ----------
147
- max_frames : Maximum frames to render before stopping automatically.
148
- ``None`` (default) runs until the window is closed or
149
- the default headless limit is reached.
160
+ max_frames : Stop after this many frames. ``None`` (default) runs
161
+ until the user closes the window.
150
162
  """
151
- from forge3d.input import _InputBuilder
152
163
  from forge3d.viewer import Viewer
153
164
 
154
- # Fire on_start
155
165
  if self._on_start is not None:
156
166
  _call_flexible(self._on_start, self._world)
157
167
 
168
+ kw: dict[str, Any] = {}
169
+ if self._sky_color is not None:
170
+ kw["sky_color"] = self._sky_color
158
171
  viewer = Viewer(
159
172
  self._world,
173
+ title=self._title,
160
174
  width=self._width,
161
175
  height=self._height,
176
+ fps=int(self._fps),
162
177
  max_frames=max_frames,
178
+ shadow_resolution=self._shadow_resolution,
179
+ show_grid=self._show_grid,
180
+ **kw,
163
181
  )
164
- inp_builder = _InputBuilder()
165
182
 
166
183
  while viewer.is_open:
167
- inp = inp_builder.build()
184
+ inp = viewer.input
168
185
 
169
186
  if self._on_update is not None:
170
187
  _call_flexible(self._on_update, self._world, self._dt, inp)
171
188
 
172
- self._world.step(self._dt)
173
-
189
+ self._world.step(self._dt, substeps=self._substeps)
174
190
  viewer.draw()
175
191
 
176
192
  if self._on_render is not None:
177
193
  _call_flexible(self._on_render, self._world, viewer)
178
194
 
179
- inp_builder.end_frame()
180
-
181
195
  viewer.close()
182
196
 
183
197
  def __repr__(self) -> str:
@@ -194,6 +208,10 @@ def _call_flexible(func: Callable, *positional: Any) -> Any:
194
208
  ``fn(world, dt, inp)`` interchangeably — missing args are omitted.
195
209
  Works with regular functions, lambdas, and bound methods.
196
210
  """
211
+ # Determine arg count from signature — only this part can fail for builtins.
212
+ # The function call itself is NOT wrapped in try/except so that errors raised
213
+ # inside the user's callback (e.g. wrong kwargs, missing imports) propagate
214
+ # immediately instead of being mistaken for an arg-count mismatch.
197
215
  try:
198
216
  sig = inspect.signature(func)
199
217
  n = len(
@@ -207,12 +225,12 @@ def _call_flexible(func: Callable, *positional: Any) -> Any:
207
225
  )
208
226
  ]
209
227
  )
210
- # If a parameter has VAR_POSITIONAL (*args), pass everything
211
228
  has_var_positional = any(
212
229
  p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()
213
230
  )
214
- if has_var_positional:
215
- return func(*positional)
216
- return func(*positional[:n])
231
+ call_args = positional if has_var_positional else positional[:n]
217
232
  except (ValueError, TypeError):
218
- return func(*positional)
233
+ # inspect.signature failed (e.g. C extension) — pass all args
234
+ call_args = positional
235
+
236
+ return func(*call_args)
@@ -69,7 +69,7 @@ def _load_wav_stdlib(path: Path) -> tuple[np.ndarray, int]:
69
69
  sr = wf.getframerate()
70
70
  raw = wf.readframes(n_frames)
71
71
 
72
- dtype_map = {1: np.int8, 2: np.int16, 4: np.int32}
72
+ dtype_map: dict[int, type[np.signedinteger]] = {1: np.int8, 2: np.int16, 4: np.int32}
73
73
  dtype = dtype_map.get(sampwidth, np.int16)
74
74
  data = np.frombuffer(raw, dtype=dtype).astype(np.float32)
75
75
  # 정규화
@@ -167,6 +167,187 @@ class OrbitCamera:
167
167
  fov_deg=self.fov_deg,
168
168
  )
169
169
 
170
+ @property
171
+ def forward_azimuth(self) -> float:
172
+ """Azimuth the camera is *looking toward* (azimuth + 180°, mod 360).
173
+
174
+ Useful as a "player forward" heading for camera-relative movement::
175
+
176
+ yaw_deg = cam.forward_azimuth
177
+ fwd = (cos(radians(yaw_deg)), sin(radians(yaw_deg)), 0)
178
+ """
179
+ return (self.azimuth + 180.0) % 360.0
180
+
181
+ def handle_input(
182
+ self,
183
+ inp: Any,
184
+ dt: float,
185
+ *,
186
+ rotate_button: int = 1,
187
+ mouse_sensitivity: float = 0.25,
188
+ rotate_key_left: str = "q",
189
+ rotate_key_right: str = "e",
190
+ pitch_key_up: str = "r",
191
+ pitch_key_down: str = "f",
192
+ key_deg_per_s: float = 130.0,
193
+ scroll_zoom: bool = True,
194
+ min_distance: float = 1.0,
195
+ max_distance: float = 50.0,
196
+ min_elevation: float = -89.0,
197
+ max_elevation: float = 89.0,
198
+ ) -> OrbitCamera:
199
+ """Process one frame of keyboard / mouse input and update the camera.
200
+
201
+ Designed to replace per-game boilerplate camera-rig update logic.
202
+ Call once per frame before :meth:`to_snapshot`::
203
+
204
+ cam.handle_input(inp, dt).follow(player_pos, dt=dt).occlude(world)
205
+ viewer.set_camera(cam.to_snapshot())
206
+
207
+ Parameters
208
+ ----------
209
+ inp : :class:`~forge3d.input.Input` or :class:`~forge3d.input.ScriptedInput`.
210
+ dt : Frame delta-time in seconds.
211
+ rotate_button : Mouse button held to orbit (default 1 = right).
212
+ mouse_sensitivity: Degrees per pixel for mouse drag.
213
+ rotate_key_left : Key to rotate left (default ``'q'``).
214
+ rotate_key_right: Key to rotate right (default ``'e'``).
215
+ pitch_key_up : Key to pitch up (default ``'r'``).
216
+ pitch_key_down : Key to pitch down (default ``'f'``).
217
+ key_deg_per_s : Rotation speed in degrees/s for key-driven orbit.
218
+ scroll_zoom : If True (default), scroll wheel zooms.
219
+ min_distance : Minimum zoom distance in metres.
220
+ max_distance : Maximum zoom distance in metres.
221
+ min_elevation : Elevation clamp lower bound (degrees).
222
+ max_elevation : Elevation clamp upper bound (degrees).
223
+
224
+ Returns ``self`` for method chaining.
225
+ """
226
+ # Mouse drag rotate
227
+ if inp.mouse_button(rotate_button):
228
+ dx, dy = inp.mouse_delta()
229
+ self.rotate(d_azimuth=-dx * mouse_sensitivity, d_elevation=dy * mouse_sensitivity)
230
+
231
+ # Key-driven rotate
232
+ k = key_deg_per_s * dt
233
+ if inp.key_held(rotate_key_left):
234
+ self.rotate(d_azimuth=k)
235
+ if inp.key_held(rotate_key_right):
236
+ self.rotate(d_azimuth=-k)
237
+ if inp.key_held(pitch_key_up):
238
+ self.rotate(d_elevation=k * 0.6)
239
+ if inp.key_held(pitch_key_down):
240
+ self.rotate(d_elevation=-k * 0.6)
241
+
242
+ # Clamp elevation
243
+ self.elevation = float(np.clip(self.elevation, min_elevation, max_elevation))
244
+
245
+ # Scroll zoom
246
+ if scroll_zoom:
247
+ sd = inp.scroll_delta()
248
+ if sd:
249
+ self.distance = float(
250
+ np.clip(
251
+ self.distance * (1.0 - sd * 0.1),
252
+ min_distance,
253
+ max_distance,
254
+ )
255
+ )
256
+
257
+ return self
258
+
259
+ def follow(
260
+ self,
261
+ target: Any,
262
+ head_height: float = 1.2,
263
+ smooth_hz: float = 10.0,
264
+ dt: float = 0.0,
265
+ ) -> OrbitCamera:
266
+ """Smoothly move the camera target toward *target* + *head_height*.
267
+
268
+ Replaces the common per-frame ``smooth_target`` lerp pattern that
269
+ third-person games need. Call once per frame before :meth:`to_snapshot`.
270
+
271
+ Parameters
272
+ ----------
273
+ target : World position to track — usually ``player.position``.
274
+ head_height : Vertical offset added to *target* (default 1.2 m).
275
+ smooth_hz : Smoothing frequency in Hz — higher = snappier.
276
+ dt : Frame delta-time; pass 0 for instant snap.
277
+
278
+ Returns ``self`` for method chaining::
279
+
280
+ cam.follow(player.position, dt=dt).occlude(world)
281
+ viewer.set_camera(cam.to_snapshot())
282
+ """
283
+ import math
284
+
285
+ goal = np.asarray(target, dtype=float) + np.array([0.0, 0.0, head_height])
286
+ alpha = 1.0 - math.exp(-smooth_hz * dt) if dt > 0.0 and smooth_hz > 0.0 else 1.0
287
+ self.target = self.target + (goal - self.target) * alpha
288
+ return self
289
+
290
+ def occlude(
291
+ self,
292
+ world: Any,
293
+ terrain_sampler: Any = None,
294
+ min_distance: float = 1.6,
295
+ layer_mask: int = 0x0001,
296
+ terrain_steps: int = 16,
297
+ terrain_clearance: float = 0.35,
298
+ ) -> OrbitCamera:
299
+ """Pull the camera distance inward to avoid geometry occlusion.
300
+
301
+ Raycasts against physics bodies and (optionally) marches the eye ray
302
+ against a terrain height sampler. Sets ``self.distance`` to the safe
303
+ distance and returns ``self`` for chaining.
304
+
305
+ Parameters
306
+ ----------
307
+ world : :class:`~forge3d.facade.World` to raycast against.
308
+ terrain_sampler : Callable ``(x, y) → z`` for terrain height (e.g.
309
+ ``heightfield.height_at`` or your own function).
310
+ Pass ``None`` to skip terrain marching — when the
311
+ world has heightfields and raycasts already hit them
312
+ this is usually not needed.
313
+ min_distance : Minimum eye-to-target distance (m).
314
+ layer_mask : Collision layers included in the occlusion raycast.
315
+ terrain_steps : Number of steps for the terrain march.
316
+ terrain_clearance: Camera is pulled in when closer than this to terrain.
317
+
318
+ Returns ``self``::
319
+
320
+ cam.follow(player.position, dt=dt).occlude(world)
321
+ viewer.set_camera(cam.to_snapshot())
322
+ """
323
+ eye_dir = self.position - self.target
324
+ eye_len = float(np.linalg.norm(eye_dir))
325
+ if eye_len < 1e-9:
326
+ return self
327
+
328
+ u = eye_dir / eye_len
329
+ dist = self.distance
330
+
331
+ # Raycast against bodies
332
+ hits = world.raycast_all(self.target, u, max_dist=dist, layer_mask=layer_mask)
333
+ for hit in hits:
334
+ if hit.body is not None:
335
+ dist = min(dist, max(min_distance, float(hit.distance) - 0.4))
336
+ break
337
+
338
+ # Optional terrain height-function march
339
+ if terrain_sampler is not None:
340
+ for k in range(1, terrain_steps + 1):
341
+ t = dist * k / terrain_steps
342
+ p = self.target + u * t
343
+ h_terrain = float(terrain_sampler(float(p[0]), float(p[1])))
344
+ if float(p[2]) < h_terrain + terrain_clearance:
345
+ dist = max(min_distance, t - 0.5)
346
+ break
347
+
348
+ self.distance = max(min_distance, dist)
349
+ return self
350
+
170
351
  def __repr__(self) -> str:
171
352
  return (
172
353
  f"OrbitCamera(target={self.target.round(2).tolist()}, "
@@ -50,6 +50,7 @@ class CharacterController:
50
50
  height: float,
51
51
  radius: float,
52
52
  ground_layer_mask: int = 0xFFFF,
53
+ ground_check_hz: float = 60.0,
53
54
  ) -> None:
54
55
  self._world = world
55
56
  self.body = body
@@ -57,7 +58,21 @@ class CharacterController:
57
58
  self._radius = float(radius)
58
59
  self._ground_layer_mask = ground_layer_mask
59
60
  self._grounded = False
60
- self._vertical_vel = 0.0 # m/s, used for jump/glide tracking
61
+ self._vertical_vel = 0.0
62
+
63
+ # Throttle ground-detection raycast. At 10 Hz (bots) this cuts the
64
+ # per-move cost from ~1 ms to ~0.017 ms with no gameplay difference.
65
+ self._ground_check_interval = 1.0 / max(1.0, float(ground_check_hz))
66
+ self._ground_check_timer = 0.0
67
+
68
+ # Jump cooldown prevents infinite jumping when the capsule is still
69
+ # close to the ground in the frame right after a jump.
70
+ self._jump_cooldown: float = 0.0
71
+
72
+ # Platform riding: track which body we're standing on and its last position
73
+ # so we can apply its displacement to ourselves each frame.
74
+ self._ground_body_id: int | None = None # None → terrain / nothing
75
+ self._ground_body_last_pos: Any | None = None # np.ndarray
61
76
 
62
77
  # ── State queries ─────────────────────────────────────────────────────────
63
78
 
@@ -91,6 +106,9 @@ class CharacterController:
91
106
  ) -> None:
92
107
  """Apply horizontal movement toward *direction* at *speed* m/s.
93
108
 
109
+ Also carries the character on moving platforms automatically — no
110
+ manual delta-passing needed.
111
+
94
112
  Parameters
95
113
  ----------
96
114
  direction : (3,) movement vector (only x/y components used unless z != 0).
@@ -98,6 +116,8 @@ class CharacterController:
98
116
  speed : Maximum movement speed in m/s.
99
117
  dt : Frame delta-time in seconds.
100
118
  """
119
+ self._update_ground(dt)
120
+
101
121
  d = np.asarray(direction, dtype=float)
102
122
  norm = np.linalg.norm(d[:2])
103
123
  if norm > 1e-9:
@@ -108,7 +128,6 @@ class CharacterController:
108
128
  cur = self.body.velocity.copy()
109
129
  cur[:2] = target_vel[:2]
110
130
  self.body.set_velocity(cur)
111
- self._update_ground(dt)
112
131
 
113
132
  def jump(self, impulse: float = 5.0) -> None:
114
133
  """Apply an upward velocity impulse if grounded.
@@ -117,11 +136,61 @@ class CharacterController:
117
136
  ----------
118
137
  impulse : Upward speed added in m/s (think: initial jump velocity).
119
138
  """
120
- if self._grounded:
139
+ if self._grounded and self._jump_cooldown <= 0.0:
121
140
  vel = self.body.velocity.copy()
122
141
  vel[2] = float(impulse)
123
142
  self.body.set_velocity(vel)
124
143
  self._grounded = False
144
+ self._jump_cooldown = 0.40 # 400 ms before the next jump is allowed
145
+
146
+ def move_camera_relative(
147
+ self,
148
+ inp: Any,
149
+ cam: Any,
150
+ speed: float,
151
+ dt: float,
152
+ *,
153
+ forward_key: str = "w",
154
+ back_key: str = "s",
155
+ left_key: str = "a",
156
+ right_key: str = "d",
157
+ ) -> np.ndarray:
158
+ """Move relative to the camera's facing direction.
159
+
160
+ Eliminates the boilerplate yaw-angle → forward/right vector pattern that
161
+ every third-person game repeats. Returns the world-space move vector
162
+ (useful for updating a ``facing`` direction)::
163
+
164
+ move = cc.move_camera_relative(inp, cam, speed=7.2, dt=dt)
165
+
166
+ Parameters
167
+ ----------
168
+ inp : :class:`~forge3d.input.Input` or
169
+ :class:`~forge3d.input.ScriptedInput`.
170
+ cam : :class:`~forge3d.camera.OrbitCamera` — only its
171
+ ``forward_azimuth`` property is used.
172
+ speed : Horizontal movement speed in m/s.
173
+ dt : Frame delta-time in seconds.
174
+ forward_key : Key for forward movement (default ``'w'``).
175
+ back_key : Key for backward movement (default ``'s'``).
176
+ left_key : Key for left movement (default ``'a'``).
177
+ right_key : Key for right movement (default ``'d'``).
178
+
179
+ Returns
180
+ -------
181
+ np.ndarray
182
+ The (3,) world-space move vector (zero if no input).
183
+ """
184
+ import math
185
+
186
+ yaw = math.radians(float(cam.forward_azimuth))
187
+ fwd = np.array([math.cos(yaw), math.sin(yaw), 0.0])
188
+ right = np.array([math.sin(yaw), -math.cos(yaw), 0.0])
189
+ mx = inp.axis(left_key, right_key)
190
+ my = inp.axis(back_key, forward_key)
191
+ move = fwd * my + right * mx
192
+ self.move(direction=tuple(move), speed=speed, dt=dt)
193
+ return move
125
194
 
126
195
  def glide(self, target_fall_speed: float = -1.5, dt: float = 1 / 60) -> None:
127
196
  """Reduce falling speed to *target_fall_speed* for a glide effect.
@@ -141,16 +210,67 @@ class CharacterController:
141
210
  # ── Internal ──────────────────────────────────────────────────────────────
142
211
 
143
212
  def _update_ground(self, dt: float) -> None:
144
- """Update is_grounded via downward raycast."""
213
+ """Update is_grounded via downward raycast (throttled + jump-cooldown aware).
214
+
215
+ Also applies platform displacement: if the body we were standing on
216
+ has moved since the last check, we teleport ourselves by the same delta.
217
+ """
218
+ # Decrement jump cooldown unconditionally every call
219
+ if self._jump_cooldown > 0.0:
220
+ self._jump_cooldown = max(0.0, self._jump_cooldown - dt)
221
+ self._grounded = False
222
+ self._ground_body_id = None
223
+ self._ground_body_last_pos = None
224
+ return
225
+
226
+ self._ground_check_timer += dt
227
+ if self._ground_check_timer < self._ground_check_interval:
228
+ # Apply stored platform delta even between checks
229
+ self._apply_platform_delta()
230
+ return
231
+ self._ground_check_timer = 0.0
232
+
233
+ # Apply platform delta from last frame before updating ground state
234
+ self._apply_platform_delta()
235
+
145
236
  pos = self.body.position
146
237
  ray_len = self._height / 2.0 + self._radius + self._GROUND_RAY_EXTRA
147
238
  hit = self._world.raycast(
148
239
  origin=pos,
149
240
  direction=(0.0, 0.0, -1.0),
150
241
  max_dist=ray_len,
242
+ layer_mask=self._ground_layer_mask,
151
243
  )
152
244
  self._grounded = hit is not None
153
245
 
246
+ # Track which body (or terrain) we're on for platform riding
247
+ if hit is not None and hit.body is not None:
248
+ self._ground_body_id = hit.body._id
249
+ self._ground_body_last_pos = hit.body.position.copy()
250
+ else:
251
+ # Terrain hit (body is None) or no hit
252
+ self._ground_body_id = None
253
+ self._ground_body_last_pos = None
254
+
255
+ def _apply_platform_delta(self) -> None:
256
+ """If standing on a moving body, carry ourselves with it."""
257
+ if self._ground_body_id is None or self._ground_body_last_pos is None:
258
+ return
259
+ body = self._world._bodies.get(self._ground_body_id)
260
+ if body is None:
261
+ self._ground_body_id = None
262
+ self._ground_body_last_pos = None
263
+ return
264
+ try:
265
+ current_pos = body.position
266
+ delta = current_pos - self._ground_body_last_pos
267
+ if np.linalg.norm(delta) > 1e-6:
268
+ self.body.set_position(self.body.position + delta)
269
+ self._ground_body_last_pos = current_pos.copy()
270
+ except Exception:
271
+ self._ground_body_id = None
272
+ self._ground_body_last_pos = None
273
+
154
274
  def __repr__(self) -> str:
155
275
  pos = self.position
156
276
  g = "grounded" if self._grounded else "airborne"