pyforge3d 2.1.1__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.1 → pyforge3d-2.2.0}/PKG-INFO +1 -1
  2. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/pyproject.toml +4 -2
  3. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/__init__.py +2 -1
  4. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/app.py +43 -25
  5. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/camera.py +181 -0
  6. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/character.py +103 -4
  7. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/detection.py +103 -7
  8. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/gjk.py +2 -2
  9. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/heightfield.py +91 -0
  10. pyforge3d-2.2.0/src/forge3d/ecs/component.py +102 -0
  11. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/system.py +14 -2
  12. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/facade.py +531 -30
  13. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/input.py +123 -3
  14. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/meshes.py +190 -0
  15. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/window_renderer.py +38 -21
  16. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/world.py +4 -0
  17. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/viewer.py +60 -9
  18. pyforge3d-2.1.1/src/forge3d/ecs/component.py +0 -56
  19. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/Cargo.lock +0 -0
  20. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/Cargo.toml +0 -0
  21. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/LICENSE +0 -0
  22. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/README.md +0 -0
  23. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/_core.pyi +0 -0
  24. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/__init__.py +0 -0
  25. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/clip.py +0 -0
  26. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/ik_fabrik.py +0 -0
  27. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/player.py +0 -0
  28. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/skeleton.py +0 -0
  29. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/system.py +0 -0
  30. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/__init__.py +0 -0
  31. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/clip.py +0 -0
  32. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/null_driver.py +0 -0
  33. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/openal_driver.py +0 -0
  34. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/source.py +0 -0
  35. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/system.py +0 -0
  36. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/backend.py +0 -0
  37. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/__init__.py +0 -0
  38. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/epa.py +0 -0
  39. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/layers.py +0 -0
  40. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/raycast.py +0 -0
  41. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/__init__.py +0 -0
  42. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/base.py +0 -0
  43. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/joint_type.py +0 -0
  44. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/joints.py +0 -0
  45. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/contact/__init__.py +0 -0
  46. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/contact/solver.py +0 -0
  47. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/__init__.py +0 -0
  48. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/aba.py +0 -0
  49. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/crba.py +0 -0
  50. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/model.py +0 -0
  51. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/rnea.py +0 -0
  52. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/__init__.py +0 -0
  53. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/bridge.py +0 -0
  54. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/entity.py +0 -0
  55. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/serialization.py +0 -0
  56. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/transform.py +0 -0
  57. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/__init__.py +0 -0
  58. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/editor_app.py +0 -0
  59. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/gizmo.py +0 -0
  60. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/layout.py +0 -0
  61. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/errors.py +0 -0
  62. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/events.py +0 -0
  63. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/__init__.py +0 -0
  64. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/mesh_data.py +0 -0
  65. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/obj_loader.py +0 -0
  66. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/world_snapshot.py +0 -0
  67. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/logging.py +0 -0
  68. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/__init__.py +0 -0
  69. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/inertia.py +0 -0
  70. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/quaternion.py +0 -0
  71. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/se3.py +0 -0
  72. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/spatial.py +0 -0
  73. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/__init__.py +0 -0
  74. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/kinematics.py +0 -0
  75. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/robot_config.py +0 -0
  76. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/urdf_loader.py +0 -0
  77. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/__init__.py +0 -0
  78. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/emitter.py +0 -0
  79. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/presets.py +0 -0
  80. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/system.py +0 -0
  81. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/profiler.py +0 -0
  82. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/py.typed +0 -0
  83. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/recorder.py +0 -0
  84. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/__init__.py +0 -0
  85. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/base.py +0 -0
  86. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/deferred/__init__.py +0 -0
  87. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/deferred/renderer.py +0 -0
  88. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/__init__.py +0 -0
  89. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/raytracer.py +0 -0
  90. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/renderer.py +0 -0
  91. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/scene.py +0 -0
  92. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/passes/__init__.py +0 -0
  93. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/passes/base.py +0 -0
  94. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/__init__.py +0 -0
  95. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/context.py +0 -0
  96. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/renderer.py +0 -0
  97. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/shaders.py +0 -0
  98. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_down.frag +0 -0
  99. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_up.frag +0 -0
  100. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/fullscreen.vert +0 -0
  101. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.frag +0 -0
  102. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.vert +0 -0
  103. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/lighting.frag +0 -0
  104. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/pbr.wgsl +0 -0
  105. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.frag +0 -0
  106. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.vert +0 -0
  107. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao.frag +0 -0
  108. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao_blur.frag +0 -0
  109. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/tonemap.frag +0 -0
  110. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/update_particles.comp +0 -0
  111. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/snapshot.py +0 -0
  112. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/__init__.py +0 -0
  113. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/pipeline.py +0 -0
  114. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/renderer.py +0 -0
  115. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/robot/__init__.py +0 -0
  116. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/robot/presets.py +0 -0
  117. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/robot/robot.py +0 -0
  118. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/__init__.py +0 -0
  119. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/manager.py +0 -0
  120. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/node.py +0 -0
  121. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/prefab.py +0 -0
  122. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/__init__.py +0 -0
  123. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/domain_rand.py +0 -0
  124. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/jax_batch.py +0 -0
  125. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/__init__.py +0 -0
  126. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/backend.py +0 -0
  127. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/canvas.py +0 -0
  128. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/panels.py +0 -0
  129. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/system.py +0 -0
  130. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/Cargo.toml +0 -0
  131. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/benches/physics_bench.rs +0 -0
  132. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/bvh.rs +0 -0
  133. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/gjk_epa.rs +0 -0
  134. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/lib.rs +0 -0
  135. {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/math_simd.rs +0 -0
  136. {pyforge3d-2.1.1 → 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.1
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
@@ -6,7 +6,7 @@ build-backend = "maturin"
6
6
 
7
7
  [project]
8
8
  name = "pyforge3d"
9
- version = "2.1.1"
9
+ version = "2.2.0"
10
10
  description = "Pure-Python 3D physics game engine — own dynamics, own rules, no compromises."
11
11
  readme = "README.md"
12
12
  license = { file = "LICENSE" }
@@ -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
 
@@ -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)
@@ -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,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)
@@ -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()}, "
@@ -63,12 +63,17 @@ class CharacterController:
63
63
  # Throttle ground-detection raycast. At 10 Hz (bots) this cuts the
64
64
  # per-move cost from ~1 ms to ~0.017 ms with no gameplay difference.
65
65
  self._ground_check_interval = 1.0 / max(1.0, float(ground_check_hz))
66
- self._ground_check_timer = 0.0
66
+ self._ground_check_timer = 0.0
67
67
 
68
68
  # Jump cooldown prevents infinite jumping when the capsule is still
69
69
  # close to the ground in the frame right after a jump.
70
70
  self._jump_cooldown: float = 0.0
71
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
76
+
72
77
  # ── State queries ─────────────────────────────────────────────────────────
73
78
 
74
79
  @property
@@ -101,6 +106,9 @@ class CharacterController:
101
106
  ) -> None:
102
107
  """Apply horizontal movement toward *direction* at *speed* m/s.
103
108
 
109
+ Also carries the character on moving platforms automatically — no
110
+ manual delta-passing needed.
111
+
104
112
  Parameters
105
113
  ----------
106
114
  direction : (3,) movement vector (only x/y components used unless z != 0).
@@ -108,6 +116,8 @@ class CharacterController:
108
116
  speed : Maximum movement speed in m/s.
109
117
  dt : Frame delta-time in seconds.
110
118
  """
119
+ self._update_ground(dt)
120
+
111
121
  d = np.asarray(direction, dtype=float)
112
122
  norm = np.linalg.norm(d[:2])
113
123
  if norm > 1e-9:
@@ -118,7 +128,6 @@ class CharacterController:
118
128
  cur = self.body.velocity.copy()
119
129
  cur[:2] = target_vel[:2]
120
130
  self.body.set_velocity(cur)
121
- self._update_ground(dt)
122
131
 
123
132
  def jump(self, impulse: float = 5.0) -> None:
124
133
  """Apply an upward velocity impulse if grounded.
@@ -134,6 +143,55 @@ class CharacterController:
134
143
  self._grounded = False
135
144
  self._jump_cooldown = 0.40 # 400 ms before the next jump is allowed
136
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
194
+
137
195
  def glide(self, target_fall_speed: float = -1.5, dt: float = 1 / 60) -> None:
138
196
  """Reduce falling speed to *target_fall_speed* for a glide effect.
139
197
 
@@ -152,26 +210,67 @@ class CharacterController:
152
210
  # ── Internal ──────────────────────────────────────────────────────────────
153
211
 
154
212
  def _update_ground(self, dt: float) -> None:
155
- """Update is_grounded via downward raycast (throttled + jump-cooldown aware)."""
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
+ """
156
218
  # Decrement jump cooldown unconditionally every call
157
219
  if self._jump_cooldown > 0.0:
158
220
  self._jump_cooldown = max(0.0, self._jump_cooldown - dt)
159
- self._grounded = False # never re-ground during cooldown
221
+ self._grounded = False
222
+ self._ground_body_id = None
223
+ self._ground_body_last_pos = None
160
224
  return
161
225
 
162
226
  self._ground_check_timer += dt
163
227
  if self._ground_check_timer < self._ground_check_interval:
228
+ # Apply stored platform delta even between checks
229
+ self._apply_platform_delta()
164
230
  return
165
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
+
166
236
  pos = self.body.position
167
237
  ray_len = self._height / 2.0 + self._radius + self._GROUND_RAY_EXTRA
168
238
  hit = self._world.raycast(
169
239
  origin=pos,
170
240
  direction=(0.0, 0.0, -1.0),
171
241
  max_dist=ray_len,
242
+ layer_mask=self._ground_layer_mask,
172
243
  )
173
244
  self._grounded = hit is not None
174
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
+
175
274
  def __repr__(self) -> str:
176
275
  pos = self.position
177
276
  g = "grounded" if self._grounded else "airborne"