hivebr 0.1.0__py3-none-any.whl
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.
- hivebr/__init__.py +20 -0
- hivebr/bridge.py +143 -0
- hivebr/core.py +207 -0
- hivebr/engine/__init__.py +15 -0
- hivebr/engine/assets/explosion.png +0 -0
- hivebr/engine/assets/mapa_1.png +0 -0
- hivebr/engine/assets/mapa_l.png +0 -0
- hivebr/engine/assets/mapa_u.png +0 -0
- hivebr/engine/assets/maze2.png +0 -0
- hivebr/engine/assets/recharge_place.png +0 -0
- hivebr/engine/camera.py +72 -0
- hivebr/engine/config.py +10 -0
- hivebr/engine/data_logger.py +627 -0
- hivebr/engine/geometry.py +14 -0
- hivebr/engine/interaction.py +66 -0
- hivebr/engine/lidar.py +67 -0
- hivebr/engine/map_generator.py +282 -0
- hivebr/engine/occupancy_grid.py +250 -0
- hivebr/engine/robots/__init__.py +61 -0
- hivebr/engine/robots/ackermann.py +172 -0
- hivebr/engine/robots/base.py +33 -0
- hivebr/engine/robots/boat.py +214 -0
- hivebr/engine/robots/differential.py +224 -0
- hivebr/engine/robots/differential_firstorder.py +79 -0
- hivebr/engine/robots/holonomic.py +110 -0
- hivebr/engine/simulator.py +1031 -0
- hivebr/engine/ui/__init__.py +0 -0
- hivebr/engine/ui/gui.py +389 -0
- hivebr/engine/waypoints.py +138 -0
- hivebr/engine/world.py +363 -0
- hivebr/kinematics.py +49 -0
- hivebr/state.py +59 -0
- hivebr-0.1.0.dist-info/LICENSE +21 -0
- hivebr-0.1.0.dist-info/METADATA +148 -0
- hivebr-0.1.0.dist-info/RECORD +37 -0
- hivebr-0.1.0.dist-info/WHEEL +5 -0
- hivebr-0.1.0.dist-info/top_level.txt +1 -0
hivebr/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""hivebr — OpenLab API for single- and multi-robot navigation experiments.
|
|
2
|
+
|
|
3
|
+
The public API is backend-agnostic and intentionally avoids importing the
|
|
4
|
+
pygame-based engine (``hivebr.engine``) at package import time, so the same
|
|
5
|
+
user script can later target the physical ROS 2 arena.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .core import HiveBrAgent, SwarmController, SwarmExperiment
|
|
9
|
+
from .state import Command, FleetState, RobotState
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HiveBrAgent",
|
|
13
|
+
"SwarmController",
|
|
14
|
+
"SwarmExperiment",
|
|
15
|
+
"Command",
|
|
16
|
+
"FleetState",
|
|
17
|
+
"RobotState",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
hivebr/bridge.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from .kinematics import normalize_command
|
|
6
|
+
from .state import Command, FleetState, RobotState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Backend(ABC):
|
|
10
|
+
"""Execution backend contract shared by the simulator and ROS 2 arena.
|
|
11
|
+
|
|
12
|
+
A backend is the only layer allowed to talk to a concrete engine, so that
|
|
13
|
+
the public API never imports pygame or ROS 2 directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def reset(self) -> None: ...
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def get_states(self) -> dict[int, RobotState]: ...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def compute(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
|
|
24
|
+
"""Return one command per robot id from a frozen fleet snapshot."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def step(self, commands: dict[int, Command], dt: float) -> None: ...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def run(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def close(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NavSim2DBackend(Backend):
|
|
37
|
+
"""Backend for local simulation using the hivebr.engine renderer/physics."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, experiment) -> None:
|
|
40
|
+
self.experiment = experiment
|
|
41
|
+
self._sim = None
|
|
42
|
+
|
|
43
|
+
def reset(self) -> None:
|
|
44
|
+
if self._sim is not None:
|
|
45
|
+
self._sim.reset()
|
|
46
|
+
|
|
47
|
+
def get_states(self) -> dict[int, RobotState]:
|
|
48
|
+
if self._sim is None:
|
|
49
|
+
return {}
|
|
50
|
+
robots = getattr(self._sim, "robots", [self._sim.robot])
|
|
51
|
+
states = {}
|
|
52
|
+
for i, robot in enumerate(robots):
|
|
53
|
+
goal = self._sim.robot_goal(i) if hasattr(self._sim, "robot_goal") else None
|
|
54
|
+
states[i] = _state_from_robot(i, robot, goal)
|
|
55
|
+
return states
|
|
56
|
+
|
|
57
|
+
def compute(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
|
|
58
|
+
return self.experiment._dispatch(states, dt)
|
|
59
|
+
|
|
60
|
+
def step(self, commands: dict[int, Command], dt: float) -> None:
|
|
61
|
+
raise NotImplementedError(
|
|
62
|
+
"Headless stepping is not available yet; use run() which drives the "
|
|
63
|
+
"engine's interactive loop through the fleet controller."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def run(self) -> None:
|
|
67
|
+
from .engine import Simulator
|
|
68
|
+
|
|
69
|
+
config = self.experiment.build_engine_config()
|
|
70
|
+
self._sim = Simulator(config=config, fleet_controller=self._fleet_controller)
|
|
71
|
+
self._sim.run()
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self._sim = None
|
|
75
|
+
|
|
76
|
+
def _fleet_controller(self, robots, goals, config, dt, lidar_readings=None):
|
|
77
|
+
states = {
|
|
78
|
+
i: _state_from_robot(i, r, goals[i] if goals is not None else None)
|
|
79
|
+
for i, r in enumerate(robots)
|
|
80
|
+
}
|
|
81
|
+
commands = self.experiment._dispatch(states, dt)
|
|
82
|
+
return [_engine_command(r, commands.get(i, Command())) for i, r in enumerate(robots)]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Ros2Backend(Backend):
|
|
86
|
+
"""Placeholder for the physical arena: one ROS 2 node per agent or a single
|
|
87
|
+
overarching node for a SwarmController."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, experiment) -> None:
|
|
90
|
+
self.experiment = experiment
|
|
91
|
+
|
|
92
|
+
def reset(self) -> None:
|
|
93
|
+
raise NotImplementedError("ROS 2 backend not implemented yet.")
|
|
94
|
+
|
|
95
|
+
def get_states(self) -> dict[int, RobotState]:
|
|
96
|
+
raise NotImplementedError("ROS 2 backend not implemented yet.")
|
|
97
|
+
|
|
98
|
+
def compute(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
|
|
99
|
+
return self.experiment._dispatch(states, dt)
|
|
100
|
+
|
|
101
|
+
def step(self, commands: dict[int, Command], dt: float) -> None:
|
|
102
|
+
raise NotImplementedError("ROS 2 backend not implemented yet.")
|
|
103
|
+
|
|
104
|
+
def run(self) -> None:
|
|
105
|
+
raise NotImplementedError("ROS 2 backend not implemented yet.")
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _state_from_robot(rid: int, robot, goal=None) -> RobotState:
|
|
112
|
+
st = robot.state()
|
|
113
|
+
goal_tuple = tuple(float(v) for v in goal) if goal is not None else None
|
|
114
|
+
return RobotState(
|
|
115
|
+
id=rid,
|
|
116
|
+
x=float(st.get("x", getattr(robot, "x", 0.0))),
|
|
117
|
+
y=float(st.get("y", getattr(robot, "y", 0.0))),
|
|
118
|
+
theta=float(st.get("theta", getattr(robot, "theta", 0.0))),
|
|
119
|
+
v=float(st.get("v", 0.0)),
|
|
120
|
+
omega=float(st.get("omega", 0.0)),
|
|
121
|
+
type=str(st.get("type", "differential")),
|
|
122
|
+
radius=float(getattr(robot, "radius", 0.1375)),
|
|
123
|
+
battery=float(getattr(robot, "battery_percent", 100.0)),
|
|
124
|
+
goal=goal_tuple,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _engine_command(robot, command: Command) -> dict:
|
|
129
|
+
rtype = str(robot.state().get("type", "differential"))
|
|
130
|
+
if rtype == "holonomic":
|
|
131
|
+
return {"vx": command.v, "vy": command.omega}
|
|
132
|
+
if rtype == "ackermann":
|
|
133
|
+
return {"speed": command.v, "steer": command.omega}
|
|
134
|
+
return {"v": command.v, "omega": command.omega}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def create_backend(name: str, experiment) -> Backend:
|
|
138
|
+
key = str(name).lower()
|
|
139
|
+
if key in ("navsim2d", "nav_sim2d", "local", "sim"):
|
|
140
|
+
return NavSim2DBackend(experiment)
|
|
141
|
+
if key in ("ros2", "ros", "arena"):
|
|
142
|
+
return Ros2Backend(experiment)
|
|
143
|
+
raise ValueError(f"Unknown backend: {name!r} (use 'navsim2d' or 'ros2')")
|
hivebr/core.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from .bridge import create_backend
|
|
8
|
+
from .kinematics import normalize_command
|
|
9
|
+
from .state import Command, RobotState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HiveBrAgent(ABC):
|
|
13
|
+
"""Decentralized (agent-centric) behavior for a single B-bot.
|
|
14
|
+
|
|
15
|
+
The engine instantiates this class once per robot and calls ``loop`` with a
|
|
16
|
+
frozen state snapshot plus the states of its neighbors.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, robot_id: int | None = None, **params):
|
|
20
|
+
self.id = robot_id
|
|
21
|
+
self.params = params
|
|
22
|
+
|
|
23
|
+
def setup(self) -> None:
|
|
24
|
+
"""Optional one-time initialization."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def loop(
|
|
28
|
+
self,
|
|
29
|
+
state: RobotState,
|
|
30
|
+
neighbors: dict[int, RobotState],
|
|
31
|
+
) -> "Command | tuple[float, float]":
|
|
32
|
+
"""Return the desired (v, omega) for this agent."""
|
|
33
|
+
|
|
34
|
+
def reset(self) -> None:
|
|
35
|
+
"""Optional per-episode reset."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SwarmController(ABC):
|
|
39
|
+
"""Centralized (global-centric) control for the whole fleet.
|
|
40
|
+
|
|
41
|
+
The engine instantiates this class exactly once and passes the states of
|
|
42
|
+
all robots, expecting one command per robot id back.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def setup(self) -> None:
|
|
46
|
+
"""Optional one-time initialization."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def loop(self, states: dict[int, RobotState]) -> dict[int, "Command | tuple[float, float]"]:
|
|
50
|
+
"""Return a mapping ``{robot_id: command}``."""
|
|
51
|
+
|
|
52
|
+
def reset(self) -> None:
|
|
53
|
+
"""Optional per-episode reset."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SwarmExperiment:
|
|
57
|
+
"""Entry point that binds user code to a backend (navsim2d or ROS 2)."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
robots=1,
|
|
62
|
+
backend: str = "navsim2d",
|
|
63
|
+
config=None,
|
|
64
|
+
dt: float = 0.03,
|
|
65
|
+
orientation: bool | None = None,
|
|
66
|
+
goal_tolerance: float | None = None,
|
|
67
|
+
comm_radius: float | None = None,
|
|
68
|
+
):
|
|
69
|
+
self.dt = float(dt)
|
|
70
|
+
self.backend_name = backend
|
|
71
|
+
self.config = config
|
|
72
|
+
self.orientation = orientation
|
|
73
|
+
self.goal_tolerance = goal_tolerance
|
|
74
|
+
self.comm_radius = comm_radius
|
|
75
|
+
self.agent_classes: dict[int, type[HiveBrAgent]] = {}
|
|
76
|
+
self.agent_params: dict[int, dict] = {}
|
|
77
|
+
self.routes: dict[int, list] = {}
|
|
78
|
+
self.controller: SwarmController | None = None
|
|
79
|
+
self.agents: dict[int, HiveBrAgent] = {}
|
|
80
|
+
self._mode: str | None = None
|
|
81
|
+
self._backend = None
|
|
82
|
+
self._num_robots, self._fleet = _parse_robots(robots)
|
|
83
|
+
|
|
84
|
+
def assign_agent(self, robot_id=None, robot_ids=None, agent_class=None, **params) -> "SwarmExperiment":
|
|
85
|
+
if agent_class is None:
|
|
86
|
+
raise ValueError("assign_agent requires an agent_class.")
|
|
87
|
+
ids = []
|
|
88
|
+
if robot_id is not None:
|
|
89
|
+
ids.append(int(robot_id))
|
|
90
|
+
if robot_ids is not None:
|
|
91
|
+
ids.extend(int(i) for i in robot_ids)
|
|
92
|
+
for rid in ids:
|
|
93
|
+
self.agent_classes[rid] = agent_class
|
|
94
|
+
self.agent_params[rid] = dict(params)
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def set_controller(self, controller: SwarmController) -> "SwarmExperiment":
|
|
98
|
+
self.controller = controller
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def assign_route(self, robot_id, waypoints) -> "SwarmExperiment":
|
|
102
|
+
"""Give a B-bot its waypoint route (each item ``(x, y)`` or ``(x, y, theta)``)."""
|
|
103
|
+
self.routes[int(robot_id)] = [list(wp) for wp in waypoints]
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def _build(self) -> None:
|
|
107
|
+
if self.controller is not None:
|
|
108
|
+
self._mode = "centralized"
|
|
109
|
+
self.controller.setup()
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self._mode = "decentralized"
|
|
113
|
+
self.agents = {
|
|
114
|
+
rid: cls(robot_id=rid, **self.agent_params.get(rid, {}))
|
|
115
|
+
for rid, cls in self.agent_classes.items()
|
|
116
|
+
}
|
|
117
|
+
for agent in self.agents.values():
|
|
118
|
+
agent.setup()
|
|
119
|
+
|
|
120
|
+
def _dispatch(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
|
|
121
|
+
if self._mode is None:
|
|
122
|
+
self._build()
|
|
123
|
+
|
|
124
|
+
if self._mode == "centralized":
|
|
125
|
+
raw = self.controller.loop(states)
|
|
126
|
+
return {int(rid): normalize_command(cmd) for rid, cmd in raw.items()}
|
|
127
|
+
|
|
128
|
+
comm_radius = self.comm_radius
|
|
129
|
+
if comm_radius is None and isinstance(self.config, dict):
|
|
130
|
+
comm_radius = self.config.get("interaction", {}).get("comm_radius")
|
|
131
|
+
|
|
132
|
+
neighbors = {}
|
|
133
|
+
for rid, st in states.items():
|
|
134
|
+
visible = {}
|
|
135
|
+
for j, other in states.items():
|
|
136
|
+
if j == rid:
|
|
137
|
+
continue
|
|
138
|
+
if comm_radius is None or math.hypot(st.x - other.x, st.y - other.y) <= comm_radius:
|
|
139
|
+
visible[j] = other
|
|
140
|
+
neighbors[rid] = visible
|
|
141
|
+
|
|
142
|
+
commands: dict[int, Command] = {}
|
|
143
|
+
for rid, st in states.items():
|
|
144
|
+
agent = self.agents.get(rid)
|
|
145
|
+
commands[rid] = normalize_command(agent.loop(st, neighbors[rid])) if agent else Command()
|
|
146
|
+
return commands
|
|
147
|
+
|
|
148
|
+
def reset(self) -> None:
|
|
149
|
+
for agent in self.agents.values():
|
|
150
|
+
agent.reset()
|
|
151
|
+
if self.controller is not None:
|
|
152
|
+
self.controller.reset()
|
|
153
|
+
if self._backend is not None:
|
|
154
|
+
self._backend.reset()
|
|
155
|
+
|
|
156
|
+
def step(self) -> None:
|
|
157
|
+
backend = self._ensure_backend()
|
|
158
|
+
states = backend.get_states()
|
|
159
|
+
commands = self._dispatch(states, self.dt)
|
|
160
|
+
backend.step(commands, self.dt)
|
|
161
|
+
|
|
162
|
+
def run(self) -> None:
|
|
163
|
+
self._build()
|
|
164
|
+
self._ensure_backend().run()
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
if self._backend is not None:
|
|
168
|
+
self._backend.close()
|
|
169
|
+
self._backend = None
|
|
170
|
+
|
|
171
|
+
def build_engine_config(self) -> dict:
|
|
172
|
+
cfg = deepcopy(self.config) if self.config is not None else None
|
|
173
|
+
if cfg is None:
|
|
174
|
+
from .engine.simulator import DEFAULT_CONFIG
|
|
175
|
+
|
|
176
|
+
cfg = deepcopy(DEFAULT_CONFIG)
|
|
177
|
+
cfg["robots"] = [deepcopy(meta) for meta in self._fleet]
|
|
178
|
+
for index, meta in enumerate(cfg["robots"]):
|
|
179
|
+
meta.setdefault("initial_pose", list(_spread_pose(index)))
|
|
180
|
+
if index in self.routes:
|
|
181
|
+
meta["waypoints"] = [list(wp) for wp in self.routes[index]]
|
|
182
|
+
|
|
183
|
+
cfg.setdefault("waypoints", {})
|
|
184
|
+
if self.orientation is not None:
|
|
185
|
+
cfg["waypoints"]["define_orientation"] = bool(self.orientation)
|
|
186
|
+
if self.goal_tolerance is not None:
|
|
187
|
+
cfg["waypoints"]["goal_tolerance"] = float(self.goal_tolerance)
|
|
188
|
+
return cfg
|
|
189
|
+
|
|
190
|
+
def _ensure_backend(self):
|
|
191
|
+
if self._backend is None:
|
|
192
|
+
self._backend = create_backend(self.backend_name, self)
|
|
193
|
+
return self._backend
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _spread_pose(index: int, spacing: float = 1.0) -> tuple[float, float, float]:
|
|
197
|
+
"""Default spawn layout: a row starting near the world origin."""
|
|
198
|
+
return (1.0 + spacing * index, 1.0, 0.0)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_robots(robots) -> tuple[int, list[dict]]:
|
|
202
|
+
if isinstance(robots, int):
|
|
203
|
+
return robots, [{"id": i} for i in range(robots)]
|
|
204
|
+
fleet = [dict(meta) for meta in robots]
|
|
205
|
+
for i, meta in enumerate(fleet):
|
|
206
|
+
meta.setdefault("id", i)
|
|
207
|
+
return len(fleet), fleet
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .config import load_config
|
|
2
|
+
from .simulator import Simulator
|
|
3
|
+
from .data_logger import DataLogger, SimLog
|
|
4
|
+
from .robots import create_fleet, create_robot
|
|
5
|
+
from . import interaction
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Simulator",
|
|
9
|
+
"load_config",
|
|
10
|
+
"DataLogger",
|
|
11
|
+
"SimLog",
|
|
12
|
+
"create_fleet",
|
|
13
|
+
"create_robot",
|
|
14
|
+
"interaction",
|
|
15
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
hivebr/engine/camera.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Camera2D:
|
|
8
|
+
world_width: float
|
|
9
|
+
world_height: float
|
|
10
|
+
viewport_width: int
|
|
11
|
+
viewport_height: int
|
|
12
|
+
initial_zoom: float = 1.0
|
|
13
|
+
min_zoom: float = 0.25
|
|
14
|
+
max_zoom: float = 8.0
|
|
15
|
+
zoom_step: float = 1.1
|
|
16
|
+
margin_x: float = 1.0
|
|
17
|
+
margin_y: float = 1.0
|
|
18
|
+
|
|
19
|
+
def __post_init__(self):
|
|
20
|
+
self.zoom = self.initial_zoom
|
|
21
|
+
self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
|
|
22
|
+
self.compute_scale()
|
|
23
|
+
|
|
24
|
+
def compute_scale(self):
|
|
25
|
+
scale_x = self.viewport_width / self.world_width
|
|
26
|
+
scale_y = self.viewport_height / self.world_height
|
|
27
|
+
self.base_scale = min(scale_x, scale_y)
|
|
28
|
+
self.scale = self.base_scale * self.zoom
|
|
29
|
+
|
|
30
|
+
def resize(self, viewport_width: int, viewport_height: int):
|
|
31
|
+
self.viewport_width = max(1, int(viewport_width))
|
|
32
|
+
self.viewport_height = max(1, int(viewport_height))
|
|
33
|
+
self.compute_scale()
|
|
34
|
+
|
|
35
|
+
def reset(self):
|
|
36
|
+
self.zoom = self.initial_zoom
|
|
37
|
+
self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
|
|
38
|
+
self.compute_scale()
|
|
39
|
+
|
|
40
|
+
def center_on_origin_with_margin(self):
|
|
41
|
+
self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
|
|
42
|
+
|
|
43
|
+
def zoom_at(self, mouse_pos, wheel_y: int):
|
|
44
|
+
old_world = self.screen_to_world(*mouse_pos)
|
|
45
|
+
factor = self.zoom_step if wheel_y > 0 else 1.0 / self.zoom_step
|
|
46
|
+
self.zoom = float(np.clip(self.zoom * factor, self.min_zoom, self.max_zoom))
|
|
47
|
+
self.compute_scale()
|
|
48
|
+
new_world = self.screen_to_world(*mouse_pos)
|
|
49
|
+
self.offset += old_world - new_world
|
|
50
|
+
|
|
51
|
+
def pan_pixels(self, dx: float, dy: float):
|
|
52
|
+
self.offset[0] -= dx / self.scale
|
|
53
|
+
self.offset[1] += dy / self.scale
|
|
54
|
+
|
|
55
|
+
def _display_offsets(self):
|
|
56
|
+
ox = (self.viewport_width - self.world_width * self.scale) / 2
|
|
57
|
+
oy = (self.viewport_height - self.world_height * self.scale) / 2
|
|
58
|
+
return ox, oy
|
|
59
|
+
|
|
60
|
+
def world_to_screen(self, px: float, py: float):
|
|
61
|
+
ox, oy = self._display_offsets()
|
|
62
|
+
px_cam = px - self.offset[0]
|
|
63
|
+
py_cam = py - self.offset[1]
|
|
64
|
+
sx = int(px_cam * self.scale + ox)
|
|
65
|
+
sy = int(self.viewport_height - (py_cam * self.scale + oy))
|
|
66
|
+
return sx, sy
|
|
67
|
+
|
|
68
|
+
def screen_to_world(self, sx: float, sy: float):
|
|
69
|
+
ox, oy = self._display_offsets()
|
|
70
|
+
px = (sx - ox) / self.scale + self.offset[0]
|
|
71
|
+
py = ((self.viewport_height - sy) - oy) / self.scale + self.offset[1]
|
|
72
|
+
return np.array([px, py], dtype=float)
|
hivebr/engine/config.py
ADDED