microecs 0.2.4__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: microecs
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
5
5
  Home-page: https://gitlab.com/meehai/microecs
6
6
  License: MIT
@@ -24,8 +24,8 @@ There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
24
24
 
25
25
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`. A component with no fields is a valid **tag** for querying (e.g. `class Frozen(Component): pass`).
26
26
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
- - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_field_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
27
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()` (see [the field numpy contract](#the-field-_field-numpy-contract) below). It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
29
29
 
30
30
  Few relevant concepts:
31
31
 
@@ -43,6 +43,7 @@ from microecs import World, Component
43
43
 
44
44
  # components
45
45
  class HasPosition(Component):
46
+ # 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
46
47
  position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
47
48
  class HasVelocity(Component):
48
49
  velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
@@ -51,14 +52,14 @@ class HasColor(Component):
51
52
 
52
53
  # systems: Note they are a convention!
53
54
  class RenderSystem:
54
- def __call__(self, world: World): # must override
55
- query_result = world.query_and((HasPosition, HasColor)) # contiguous-like view of all entities matching
55
+ def __call__(self, world: World):
56
+ query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
56
57
  for position, color in zip(query_result.position, query_result.color): # draw each entity
57
58
  DrawEntity(position, color)
58
59
 
59
60
  class MotionSystem:
60
- def __call__(self, world: World): # must override
61
- qr = world.query_and((HasPosition, HasVelocity))
61
+ def __call__(self, world: World):
62
+ qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
62
63
  qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
63
64
  # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
64
65
  for pool in qr.pool_list:
@@ -68,7 +69,7 @@ def main():
68
69
  render_system: list[Callable] = RenderSystem()
69
70
  update_systems: list[Callable] = [MotionSystem()]
70
71
 
71
- world = World(components=[HasPosition, HasColor, HasVelocity], extra_field_metadata=None)
72
+ world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
72
73
  for _ in range(n_objects):
73
74
  # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
74
75
  world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
@@ -87,3 +88,32 @@ def main():
87
88
  render_system(world=world)
88
89
  rl.EndDrawing()
89
90
  ```
91
+
92
+ ## The field (`_Field`) numpy contract
93
+
94
+ `qr.position` returns a `_Field`: a view over the matching pools that behaves like one `(N, *e)`
95
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches the
96
+ result back. So the contract is exactly:
97
+
98
+ > Any op that treats rows independently behaves **identically to numpy** on the concatenated
99
+ > `(N, *e)` array — same values, same shape, and the same error on bad shapes.
100
+
101
+ That covers elementwise math and ufuncs, broadcasting (every operand shape numpy accepts, and it
102
+ raises on the ones numpy rejects), comparisons, `np.where` / `np.clip`, batched `np.matmul`,
103
+ **feature-axis** reductions/indexing (`np.linalg.norm(qr.velocity, axis=1)`, `qr.pose[:, 0]`), and
104
+ write-back broadcasting (`qr.position[:] = <scalar | (*e,) | (N, *e)>`). Pinned in
105
+ `test/unit/test_field_numpy_parity.py`.
106
+
107
+ Edge cases worth knowing:
108
+
109
+ - **Not a full ndarray — these *raise*, never lie.** Entity-axis indexing beyond a single
110
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
111
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
112
+ first with `qr.f.numpy()`.
113
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
114
+ `axis=0` run *within each pool* and reset at pool boundaries — they do **not** see all entities
115
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
116
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
117
+ longer matches the pool's row count.
118
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
119
+ mix a `_Field` from one `world.query(...)` into an op on another.
@@ -12,8 +12,8 @@ There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
12
12
 
13
13
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`. A component with no fields is a valid **tag** for querying (e.g. `class Frozen(Component): pass`).
14
14
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
15
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
16
- - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_field_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
15
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()` (see [the field numpy contract](#the-field-_field-numpy-contract) below). It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
16
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
17
17
 
18
18
  Few relevant concepts:
19
19
 
@@ -31,6 +31,7 @@ from microecs import World, Component
31
31
 
32
32
  # components
33
33
  class HasPosition(Component):
34
+ # 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
34
35
  position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
35
36
  class HasVelocity(Component):
36
37
  velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
@@ -39,14 +40,14 @@ class HasColor(Component):
39
40
 
40
41
  # systems: Note they are a convention!
41
42
  class RenderSystem:
42
- def __call__(self, world: World): # must override
43
- query_result = world.query_and((HasPosition, HasColor)) # contiguous-like view of all entities matching
43
+ def __call__(self, world: World):
44
+ query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
44
45
  for position, color in zip(query_result.position, query_result.color): # draw each entity
45
46
  DrawEntity(position, color)
46
47
 
47
48
  class MotionSystem:
48
- def __call__(self, world: World): # must override
49
- qr = world.query_and((HasPosition, HasVelocity))
49
+ def __call__(self, world: World):
50
+ qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
50
51
  qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
51
52
  # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
52
53
  for pool in qr.pool_list:
@@ -56,7 +57,7 @@ def main():
56
57
  render_system: list[Callable] = RenderSystem()
57
58
  update_systems: list[Callable] = [MotionSystem()]
58
59
 
59
- world = World(components=[HasPosition, HasColor, HasVelocity], extra_field_metadata=None)
60
+ world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
60
61
  for _ in range(n_objects):
61
62
  # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
62
63
  world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
@@ -75,3 +76,32 @@ def main():
75
76
  render_system(world=world)
76
77
  rl.EndDrawing()
77
78
  ```
79
+
80
+ ## The field (`_Field`) numpy contract
81
+
82
+ `qr.position` returns a `_Field`: a view over the matching pools that behaves like one `(N, *e)`
83
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches the
84
+ result back. So the contract is exactly:
85
+
86
+ > Any op that treats rows independently behaves **identically to numpy** on the concatenated
87
+ > `(N, *e)` array — same values, same shape, and the same error on bad shapes.
88
+
89
+ That covers elementwise math and ufuncs, broadcasting (every operand shape numpy accepts, and it
90
+ raises on the ones numpy rejects), comparisons, `np.where` / `np.clip`, batched `np.matmul`,
91
+ **feature-axis** reductions/indexing (`np.linalg.norm(qr.velocity, axis=1)`, `qr.pose[:, 0]`), and
92
+ write-back broadcasting (`qr.position[:] = <scalar | (*e,) | (N, *e)>`). Pinned in
93
+ `test/unit/test_field_numpy_parity.py`.
94
+
95
+ Edge cases worth knowing:
96
+
97
+ - **Not a full ndarray — these *raise*, never lie.** Entity-axis indexing beyond a single
98
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
99
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
100
+ first with `qr.f.numpy()`.
101
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
102
+ `axis=0` run *within each pool* and reset at pool boundaries — they do **not** see all entities
103
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
104
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
105
+ longer matches the pool's row count.
106
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
107
+ mix a `_Field` from one `world.query(...)` into an op on another.
@@ -6,8 +6,6 @@ from .utils import Shape
6
6
  from .pool import Pool
7
7
 
8
8
  class _Field(np.lib.mixins.NDArrayOperatorsMixin):
9
- _PER_POOL_OK = {np.where, np.clip}
10
-
11
9
  def __init__(self, parts: list[np.ndarray]):
12
10
  self.parts = parts
13
11
  self._lens = [len(p) for p in self.parts]
@@ -27,14 +25,20 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
27
25
  return x
28
26
 
29
27
  def _apply_fn_on_parts(self, fn: Callable, op_args: list, **kwargs):
28
+ # op args can be 1 element (-qr.velocity), 2 elements (qr.position * 0.1), 3 elements (np.where(a, b, c)), etc.
29
+ # all of them must be chunked based on how many we have in this _Field so each subpart is called independently.
30
30
  results = []
31
- for i in range(len(self.parts)):
31
+ for i, part in enumerate(self.parts):
32
32
  pool_args = [self._chunk(x, i) for x in op_args]
33
- results.append(fn(*pool_args, **kwargs))
33
+ part_result: _Field = fn(*pool_args, **kwargs)
34
+ # we expect f(arr(N, ...)) -> arr(N, ...) where N = number of items in the pool
35
+ # for e.g. np.linalg.norm(velocity, axis=1) should do (N, 2) -> (N, 1) so the first axis is preserved
36
+ assert len(part_result) == part.shape[0], f"Result: {part_result.shape} vs {part.shape}"
37
+ results.append(part_result)
34
38
  return _Field(results)
35
39
 
36
40
  def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
37
- """wrapper forelementwise (python) primitives, e.g. qr.position[:] += 1"""
41
+ """wrapper for elementwise (python) primitives, e.g. qr.position[:] += 1"""
38
42
  if method != "__call__":
39
43
  return NotImplemented
40
44
  if out is None:
@@ -48,8 +52,6 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
48
52
 
49
53
  def __array_function__(self, func: Callable, _types, args: list, kwargs: dict):
50
54
  """wrapper for elementwise numpy functions, e.g. qr.velocity[:] = np.where(mask, -qr.velocity, qr.velocity)"""
51
- if func not in self._PER_POOL_OK:
52
- return NotImplemented # add them manually
53
55
  return self._apply_fn_on_parts(func, args, **kwargs)
54
56
 
55
57
  # qr.position[:] = <field | scalar | per-entity broadcast> -> scatter through the views
@@ -1,6 +1,7 @@
1
1
  """world.py - The world container for ECS. It manages all the pools (one per archetype). Entities are id-based."""
2
2
  from typing import Callable, get_type_hints
3
3
  from functools import partial
4
+ from dataclasses import fields
4
5
  import numpy as np
5
6
 
6
7
  from .pool import Pool
@@ -13,30 +14,32 @@ class World:
13
14
  Generic container for pools of components. Newly added components are assigned a unique id and go in a pool
14
15
  Parameters
15
16
  - components The list of components that the world accepts
16
- - extra_field_metadata The list of required extra metadata for each field besides shape and dtype.
17
+ - extra_metadata The list of required extra metadata for each field besides shape and dtype.
17
18
  """
18
- def __init__(self, components: list[ComponentType], extra_field_metadata: list[str] | None = None):
19
- self.extra_field_metadata = extra_field_metadata or []
19
+ def __init__(self, components: list[ComponentType], extra_metadata: list[str] | None = None):
20
+ self.extra_metadata = extra_metadata or []
21
+ assert isinstance(self.extra_metadata, list), type(self.extra_metadata)
20
22
  self._check_components(components)
21
23
  self.pools: dict[PoolKey, Pool] = {}
22
24
  self.pool_to_components: dict[Pool, list[ComponentType]] = {}
23
25
  # components management
24
26
  self.component_names = [x.__name__ for x in components]
25
27
  self.component_types = list(components)
26
- self.component_to_bit: dict[type, int] = {t: 2**i for i, t in enumerate(components)} # unique bit for querying
27
- self.component_to_shapes: dict[type, list[Shape]] = {
28
- t: [f.metadata["shape"] for f in t.__dataclass_fields__.values()] for t in components}
29
- self.component_to_dtypes: dict[type, list[str]] = {
30
- t: [f.metadata["dtype"] for f in t.__dataclass_fields__.values()] for t in components}
31
- self.component_to_field_names: dict[type, list[str]] = {t: list(t.__dataclass_fields__) for t in components}
28
+ self.component_to_bit: dict[ComponentType, int] = {t: 2**i for i, t in enumerate(components)} # bit for querying
29
+ self.component_to_shapes: dict[ComponentType, list[Shape]] = {
30
+ c: [f.metadata["shape"] for f in fields(c)] for c in components}
31
+ self.component_to_dtypes: dict[ComponentType, list[str]] = {
32
+ c: [f.metadata["dtype"] for f in fields(c)] for c in components}
33
+ self.component_to_field_names: dict[ComponentType, list[str]] = {
34
+ c: [f.name for f in fields(c)] for c in components}
32
35
  # entity id management
33
36
  self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
34
37
  self._pool_ids: dict[Pool, list[EntityId]] = {}
35
38
  self._last_id: EntityId = -1
36
- self._live_ids: set[EntityId] = set() # 'eager' mode ids so e.g. calling remove_entity twice before update fails
39
+ self.live_ids: set[EntityId] = set() # 'eager' mode ids so e.g. calling remove_entity twice before update fails
37
40
  # command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
38
41
  self._command_buffer: list[Callable] = []
39
- self._cache: dict[PoolKey, QueryResult] = {}
42
+ self._cache: dict[tuple[PoolKey, PoolKey], QueryResult] = {} # include+exclude key
40
43
  logger.debug(f"Created scene with components: {self.component_names}")
41
44
 
42
45
  # public api
@@ -54,15 +57,16 @@ class World:
54
57
  """Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
55
58
  self._check_components_against_pool(components, **kwargs)
56
59
  self._last_id += 1
57
- self._live_ids.add(self._last_id)
60
+ self.live_ids.add(self._last_id)
58
61
  self._command_buffer.append(partial(self._add_to_pool, entity_id=self._last_id,
59
62
  components=components, **kwargs))
63
+ logger.debug(f"Created entity. ID: {self._last_id}. Components: {[c.__name__ for c in components]}")
60
64
  return self._last_id
61
65
 
62
66
  def remove_entity(self, entity_id: EntityId):
63
67
  """Removes an entity based on its unique entity id. Lazy; call update()"""
64
- assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
65
- self._live_ids.remove(entity_id)
68
+ assert entity_id in self.live_ids, f"Entity id: {entity_id} not in {self.live_ids=}"
69
+ self.live_ids.remove(entity_id)
66
70
  self._command_buffer.append(partial(self._pop_from_pool, entity_id=entity_id))
67
71
 
68
72
  def get_entity(self, entity_id: EntityId) -> tuple[EntityData, list[ComponentType]]:
@@ -75,30 +79,44 @@ class World:
75
79
 
76
80
  def add_component(self, entity_id: EntityId, component: ComponentType, **kwargs):
77
81
  """Adds a component to an entity given its id. Component data is sent to kwargs. Lazy; call update()"""
78
- assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
82
+ assert entity_id in self.live_ids, f"Entity id: {entity_id} not in {self.live_ids=}"
79
83
  assert component in self.component_types, f"Component '{component}' not in {self.component_types}"
80
84
  self._command_buffer.append(partial(self._do_add_component, entity_id=entity_id, component=component, **kwargs))
81
85
 
82
86
  def remove_component(self, entity_id: EntityId, component: ComponentType):
83
87
  """Removes a component from an entity given its id. Lazy; call update()"""
84
- assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
88
+ assert entity_id in self.live_ids, f"Entity id: {entity_id} not in {self.live_ids=}"
85
89
  assert component in self.component_types, f"Component '{component}' not in {self.component_types}"
86
90
  self._command_buffer.append(partial(self._do_remove_component, entity_id=entity_id, component=component))
87
91
 
88
- def query_and(self, component_types: list[ComponentType]) -> QueryResult:
89
- """returns A QueryResult object with the entities that have all the requested components (entity ids too)."""
92
+ def query(self, *include: ComponentType, exclude: list[ComponentType] | None = None) -> QueryResult:
93
+ """
94
+ Queries the world for entities that match the include set of components.
95
+ Syntax: `world.query(A, B, exclude=[C, D])` is, in logic form, a chain of 'ands': `A & B & ~C & ~D`.
96
+ Return: A `QueryResult` object with the entities that have all the requested components. Has `EntityIds` too.
97
+ """
98
+
90
99
  # Note: we can cache the queries. The only time it can get invalidated (via public API) is at update().
91
- if (key := self._make_key(component_types)) in self._cache:
100
+ include_key = self._make_key(include)
101
+ exclude_key = self._make_key(exclude or [])
102
+ if (key := (include_key, exclude_key)) in self._cache:
92
103
  return self._cache[key]
93
104
 
105
+ # archetype_key = (1 0 0 1 1) &
106
+ # key = (1 0 0 0 1)
107
+ # -> (1 0 0 0 1) OK
108
+ # but
109
+ # archetype_key = (1 0 0 1 1) &
110
+ # key = (1 0 1 0 0)
111
+ # -> (1 0 0 0 0) NOT OK
94
112
  res = []
95
113
  for archetype_key, archetype_pool in self.pools.items():
96
- if (archetype_key & key) == key: # key is subset of archetype_key
114
+ if (archetype_key & include_key) == include_key and (archetype_key & exclude_key) == 0:
97
115
  res.append(archetype_pool)
98
116
 
99
- field_names = sum([self.component_to_field_names[c] for c in component_types], [])
100
- field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in component_types], [])))
101
- field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in component_types], [])))
117
+ field_names = sum([self.component_to_field_names[c] for c in include], [])
118
+ field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in include], [])))
119
+ field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in include], [])))
102
120
  entity_ids = np.array(sum((self._pool_ids[p] for p in res), []), dtype="int64")
103
121
 
104
122
  self._cache[key] = QueryResult(res, field_shapes=field_shapes, field_dtypes=field_dtypes, entity_ids=entity_ids)
@@ -156,15 +174,15 @@ class World:
156
174
  for component in components:
157
175
  for _field in self.component_to_field_names[component]:
158
176
  expected_fields.add(_field)
159
- assert _field in entity_fields, f"Entity doesn't have '{component}/{_field}'"
177
+ assert _field in entity_fields, f"Entity doesn't have '{component.__name__}/{_field}'"
160
178
  assert (extra := set(entity_fields) - expected_fields) == set(), f"Extra fields: {extra}; {expected_fields=}"
161
179
 
162
180
  def _get_entity_pool(self, components: list[ComponentType]) -> Pool:
163
181
  if (key := self._make_key(components)) not in self.pools:
164
- fields = sum([self.component_to_field_names[component] for component in components], []) # merge fields
182
+ _fields = sum([self.component_to_field_names[component] for component in components], []) # merge fields
165
183
  shapes = sum([self.component_to_shapes[component] for component in components], []) # merge shapes
166
184
  dtypes = sum([self.component_to_dtypes[component] for component in components], []) # merge dtypes
167
- self.pools[key] = Pool(fields, shapes, dtypes)
185
+ self.pools[key] = Pool(_fields, shapes, dtypes)
168
186
  self.pool_to_components[self.pools[key]] = components
169
187
  return self.pools[key]
170
188
 
@@ -176,21 +194,23 @@ class World:
176
194
  return key
177
195
 
178
196
  def _check_components(self, components: list[ComponentType]):
179
- _query_result_reserved_names = _qres = sorted(vars(QueryResult([], {}, {}, [])))
180
- _dtypes = {"float32", "int32", "bool", "str", "object"}
197
+ qr_reserved_names = _qres = sorted(vars(QueryResult([], {}, {}, [])))
198
+ dtypes = {"float32", "int32", "bool", "str", "object"}
199
+ expected_meta = {"shape", "dtype", *self.extra_metadata}
181
200
 
182
201
  for component in components:
183
- assert hasattr(component, "__dataclass_fields__"), f"Component '{component}' is not a dataclass"
202
+ cn = component.__name__
203
+ assert hasattr(component, "__dataclass_fields__"), f"Component '{cn}' is not a dataclass"
184
204
  hints = get_type_hints(component) # make it work with from __future__ import annotations
185
- for field_name, _field in component.__dataclass_fields__.items():
186
- assert hints[field_name] is np.ndarray, f"Field '{field_name}' of '{component=}' not an array: {_field}"
187
- assert _field.metadata.keys() == {"shape", "dtype", *self.extra_field_metadata}, _field.metadata
188
- assert isinstance(_field.metadata["shape"], tuple), _field.metadata["shape"]
189
- assert isinstance(fmd := _field.metadata["dtype"], str) and fmd in _dtypes, f"{fmd} not in {_dtypes}"
190
- assert field_name not in _query_result_reserved_names, f"Field '{field_name}' in {_qres}"
205
+ for f in fields(component):
206
+ assert hints[f.name] is np.ndarray, f"Field '{cn}/{f.name}' not an array: {f}"
207
+ assert f.name not in qr_reserved_names, f"Field '{cn}/{f.name}' in {qr_reserved_names}"
208
+ assert f.metadata.keys() == expected_meta, f"Field '{cn}/{f.name}':\n{f.metadata} vs\n{expected_meta}"
209
+ assert isinstance(f.metadata["shape"], tuple), f.metadata["shape"]
210
+ assert isinstance(fmd := f.metadata["dtype"], str) and fmd in dtypes, f"{fmd} not in {dtypes}"
191
211
 
192
212
  def __len__(self):
193
- return len(self._live_ids)
213
+ return len(self.live_ids)
194
214
 
195
215
  def __repr__(self):
196
216
  return (f"[World]\n- Entities: {len(self)} (last id: {self._last_id})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: microecs
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
5
5
  Home-page: https://gitlab.com/meehai/microecs
6
6
  License: MIT
@@ -24,8 +24,8 @@ There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
24
24
 
25
25
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`. A component with no fields is a valid **tag** for querying (e.g. `class Frozen(Component): pass`).
26
26
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
- - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_field_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
27
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()` (see [the field numpy contract](#the-field-_field-numpy-contract) below). It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
29
29
 
30
30
  Few relevant concepts:
31
31
 
@@ -43,6 +43,7 @@ from microecs import World, Component
43
43
 
44
44
  # components
45
45
  class HasPosition(Component):
46
+ # 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
46
47
  position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
47
48
  class HasVelocity(Component):
48
49
  velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
@@ -51,14 +52,14 @@ class HasColor(Component):
51
52
 
52
53
  # systems: Note they are a convention!
53
54
  class RenderSystem:
54
- def __call__(self, world: World): # must override
55
- query_result = world.query_and((HasPosition, HasColor)) # contiguous-like view of all entities matching
55
+ def __call__(self, world: World):
56
+ query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
56
57
  for position, color in zip(query_result.position, query_result.color): # draw each entity
57
58
  DrawEntity(position, color)
58
59
 
59
60
  class MotionSystem:
60
- def __call__(self, world: World): # must override
61
- qr = world.query_and((HasPosition, HasVelocity))
61
+ def __call__(self, world: World):
62
+ qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
62
63
  qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
63
64
  # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
64
65
  for pool in qr.pool_list:
@@ -68,7 +69,7 @@ def main():
68
69
  render_system: list[Callable] = RenderSystem()
69
70
  update_systems: list[Callable] = [MotionSystem()]
70
71
 
71
- world = World(components=[HasPosition, HasColor, HasVelocity], extra_field_metadata=None)
72
+ world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
72
73
  for _ in range(n_objects):
73
74
  # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
74
75
  world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
@@ -87,3 +88,32 @@ def main():
87
88
  render_system(world=world)
88
89
  rl.EndDrawing()
89
90
  ```
91
+
92
+ ## The field (`_Field`) numpy contract
93
+
94
+ `qr.position` returns a `_Field`: a view over the matching pools that behaves like one `(N, *e)`
95
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches the
96
+ result back. So the contract is exactly:
97
+
98
+ > Any op that treats rows independently behaves **identically to numpy** on the concatenated
99
+ > `(N, *e)` array — same values, same shape, and the same error on bad shapes.
100
+
101
+ That covers elementwise math and ufuncs, broadcasting (every operand shape numpy accepts, and it
102
+ raises on the ones numpy rejects), comparisons, `np.where` / `np.clip`, batched `np.matmul`,
103
+ **feature-axis** reductions/indexing (`np.linalg.norm(qr.velocity, axis=1)`, `qr.pose[:, 0]`), and
104
+ write-back broadcasting (`qr.position[:] = <scalar | (*e,) | (N, *e)>`). Pinned in
105
+ `test/unit/test_field_numpy_parity.py`.
106
+
107
+ Edge cases worth knowing:
108
+
109
+ - **Not a full ndarray — these *raise*, never lie.** Entity-axis indexing beyond a single
110
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
111
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
112
+ first with `qr.f.numpy()`.
113
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
114
+ `axis=0` run *within each pool* and reset at pool boundaries — they do **not** see all entities
115
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
116
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
117
+ longer matches the pool's row count.
118
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
119
+ mix a `_Field` from one `world.query(...)` into an op on another.
@@ -3,7 +3,7 @@ from pathlib import Path
3
3
  from setuptools import setup, find_packages
4
4
 
5
5
  NAME = "microecs"
6
- VERSION = "0.2.4"
6
+ VERSION = "0.3.1"
7
7
  DESCRIPTION = "MicroECS: Minimal Entity Component System (ECS) in python and numpy"
8
8
  URL = "https://gitlab.com/meehai/microecs"
9
9
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes