sonolus.py 0.3.1__py3-none-any.whl → 0.3.3__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.

Potentially problematic release.


This version of sonolus.py might be problematic. Click here for more details.

Files changed (36) hide show
  1. sonolus/backend/finalize.py +16 -4
  2. sonolus/backend/node.py +13 -5
  3. sonolus/backend/optimize/allocate.py +41 -4
  4. sonolus/backend/optimize/flow.py +24 -7
  5. sonolus/backend/optimize/optimize.py +2 -9
  6. sonolus/backend/utils.py +6 -1
  7. sonolus/backend/visitor.py +72 -23
  8. sonolus/build/cli.py +6 -1
  9. sonolus/build/engine.py +1 -1
  10. sonolus/script/archetype.py +52 -24
  11. sonolus/script/array.py +20 -8
  12. sonolus/script/array_like.py +30 -3
  13. sonolus/script/containers.py +27 -7
  14. sonolus/script/debug.py +66 -8
  15. sonolus/script/globals.py +17 -0
  16. sonolus/script/internal/builtin_impls.py +12 -8
  17. sonolus/script/internal/context.py +55 -1
  18. sonolus/script/internal/range.py +25 -2
  19. sonolus/script/internal/simulation_context.py +131 -0
  20. sonolus/script/internal/tuple_impl.py +18 -11
  21. sonolus/script/interval.py +60 -2
  22. sonolus/script/iterator.py +3 -2
  23. sonolus/script/num.py +11 -2
  24. sonolus/script/options.py +24 -1
  25. sonolus/script/quad.py +41 -3
  26. sonolus/script/record.py +24 -3
  27. sonolus/script/runtime.py +411 -0
  28. sonolus/script/stream.py +133 -16
  29. sonolus/script/transform.py +291 -2
  30. sonolus/script/values.py +9 -3
  31. sonolus/script/vec.py +14 -2
  32. {sonolus_py-0.3.1.dist-info → sonolus_py-0.3.3.dist-info}/METADATA +1 -1
  33. {sonolus_py-0.3.1.dist-info → sonolus_py-0.3.3.dist-info}/RECORD +36 -35
  34. {sonolus_py-0.3.1.dist-info → sonolus_py-0.3.3.dist-info}/WHEEL +0 -0
  35. {sonolus_py-0.3.1.dist-info → sonolus_py-0.3.3.dist-info}/entry_points.txt +0 -0
  36. {sonolus_py-0.3.1.dist-info → sonolus_py-0.3.3.dist-info}/licenses/LICENSE +0 -0
@@ -19,6 +19,25 @@ _compiler_internal_ = True
19
19
  context_var = ContextVar("context_var", default=None)
20
20
 
21
21
 
22
+ @dataclass(frozen=True)
23
+ class DebugConfig:
24
+ unchecked_reads: bool
25
+ unchecked_writes: bool
26
+
27
+
28
+ _full_debug_config = DebugConfig(
29
+ unchecked_reads=True,
30
+ unchecked_writes=True,
31
+ )
32
+
33
+ _disabled_debug_config = DebugConfig(
34
+ unchecked_reads=False,
35
+ unchecked_writes=False,
36
+ )
37
+
38
+ debug_var = ContextVar("debug_var", default=_disabled_debug_config)
39
+
40
+
22
41
  class GlobalContextState:
23
42
  archetypes: dict[type, int]
24
43
  rom: ReadOnlyMemory
@@ -92,12 +111,16 @@ class Context:
92
111
  return self.callback_state.used_names
93
112
 
94
113
  def check_readable(self, place: BlockPlace):
114
+ if debug_config().unchecked_reads:
115
+ return
95
116
  if not self.callback:
96
117
  return
97
118
  if isinstance(place.block, BlockData) and self.callback not in self.blocks(place.block).readable:
98
119
  raise RuntimeError(f"Block {place.block} is not readable in {self.callback}")
99
120
 
100
121
  def check_writable(self, place: BlockPlace):
122
+ if debug_config().unchecked_writes:
123
+ return
101
124
  if not self.callback:
102
125
  return
103
126
  if isinstance(place.block, BlockData) and self.callback not in self.blocks(place.block).writable:
@@ -238,6 +261,12 @@ class Context:
238
261
  context.outgoing[None] = target
239
262
  return target
240
263
 
264
+ def register_archetype(self, type_: type) -> int:
265
+ with self.global_state.lock:
266
+ if type_ not in self.global_state.archetypes:
267
+ self.global_state.archetypes[type_] = len(self.global_state.archetypes)
268
+ return self.global_state.archetypes[type_]
269
+
241
270
 
242
271
  def ctx() -> Context | None:
243
272
  return context_var.get()
@@ -262,7 +291,11 @@ class ReadOnlyMemory:
262
291
  _lock: Lock
263
292
 
264
293
  def __init__(self):
265
- self.values = []
294
+ self.values = [
295
+ float("nan"),
296
+ float("inf"),
297
+ float("-inf"),
298
+ ]
266
299
  self.indexes = {}
267
300
  self._lock = Lock()
268
301
 
@@ -283,6 +316,27 @@ class ReadOnlyMemory:
283
316
  else:
284
317
  return PlayBlock.EngineRom
285
318
 
319
+ def get_value(self, index: int) -> float:
320
+ with self._lock:
321
+ if index < 0 or index >= len(self.values):
322
+ raise IndexError(f"Index {index} out of bounds for ReadOnlyMemory")
323
+ return self.values[index]
324
+
325
+
326
+ @contextmanager
327
+ def enable_debug(config: DebugConfig | None = None):
328
+ if config is None:
329
+ config = _full_debug_config
330
+ token = debug_var.set(config)
331
+ try:
332
+ yield
333
+ finally:
334
+ debug_var.reset(token)
335
+
336
+
337
+ def debug_config() -> DebugConfig:
338
+ return debug_var.get()
339
+
286
340
 
287
341
  @dataclass
288
342
  class ValueBinding:
@@ -1,4 +1,6 @@
1
- from sonolus.script.array_like import ArrayLike
1
+ from sonolus.script.array_like import ArrayLike, get_positive_index
2
+ from sonolus.script.internal.context import ctx
3
+ from sonolus.script.internal.impl import meta_fn, validate_value
2
4
  from sonolus.script.iterator import SonolusIterator
3
5
  from sonolus.script.num import Num
4
6
  from sonolus.script.record import Record
@@ -36,7 +38,7 @@ class Range(Record, ArrayLike[Num]):
36
38
  return (diff - self.step - 1) // -self.step
37
39
 
38
40
  def __getitem__(self, index: Num) -> Num:
39
- return self.start + index * self.step
41
+ return self.start + get_positive_index(index, len(self)) * self.step
40
42
 
41
43
  def __setitem__(self, index: Num, value: Num):
42
44
  raise TypeError("Range does not support item assignment")
@@ -79,3 +81,24 @@ class RangeIterator(Record, SonolusIterator):
79
81
 
80
82
  def advance(self):
81
83
  self.value += self.step
84
+
85
+
86
+ @meta_fn
87
+ def range_or_tuple(start: Num, stop: Num | None = None, step: Num = 1) -> Range | tuple[Num, ...]:
88
+ if stop is None:
89
+ start, stop = 0, start
90
+ if not ctx():
91
+ return range(start, stop, step) # type: ignore
92
+ start = Num._accept_(start)
93
+ stop = Num._accept_(stop) if stop is not None else None
94
+ step = Num._accept_(step)
95
+ if start._is_py_() and stop._is_py_() and step._is_py_():
96
+ start_int = start._as_py_()
97
+ stop_int = stop._as_py_() if stop is not None else None
98
+ if stop_int is None:
99
+ start_int, stop_int = 0, start_int
100
+ step_int = step._as_py_()
101
+ if start_int % 1 != 0 or stop_int % 1 != 0 or step_int % 1 != 0:
102
+ raise TypeError("Range arguments must be integers")
103
+ return validate_value(tuple(range(int(start_int), int(stop_int), int(step_int)))) # type: ignore
104
+ return Range(start, stop, step)
@@ -0,0 +1,131 @@
1
+ import builtins
2
+ import sys
3
+ from collections.abc import Callable
4
+ from types import MethodType, ModuleType
5
+ from typing import Any, ClassVar, Self
6
+
7
+ from sonolus.script.internal.impl import validate_value
8
+
9
+ _MISSING = object()
10
+
11
+
12
+ class SimulationContext:
13
+ """Context manager to simulate additional Sonolus runtime features like level memory."""
14
+
15
+ _active_context: ClassVar[Self | None] = None
16
+ additional_replacements: dict[Any, Any]
17
+ values: dict[Any, Any]
18
+ _original_values: dict[str, dict[str, Any]] # module_name: {var_name: original_value}
19
+ _original_import: Callable[[str, Any, Any, Any, Any], Any] | None
20
+
21
+ def __init__(self, *, additional_replacements: dict[Any, Any] | None = None):
22
+ if SimulationContext._active_context is not None:
23
+ raise RuntimeError("SimulationContext is already active")
24
+ SimulationContext._active_context = self
25
+ self.additional_replacements = additional_replacements or {}
26
+ self.values = {}
27
+ self._original_values = {}
28
+ self._original_import = None
29
+
30
+ def get_or_put_value(self, key: Any, factory: Callable[[], Any]) -> Any:
31
+ if key not in self.values:
32
+ self.values[key] = validate_value(factory())
33
+ return self.values[key]._get_()._as_py_()
34
+
35
+ def set_or_put_value(self, key: Any, factory: Callable[[], Any], value: Any):
36
+ if key not in self.values:
37
+ self.values[key] = validate_value(factory())
38
+ existing_value = self.values[key]
39
+ existing_type = type(existing_value)
40
+ value = existing_type._accept_(validate_value(value))
41
+ if existing_type._is_value_type_():
42
+ existing_value._set_(value)
43
+ else:
44
+ existing_value._copy_from_(value)
45
+
46
+ def _get_replacement(self, value: Any) -> Any:
47
+ try:
48
+ if value in self.additional_replacements:
49
+ return self.additional_replacements[value]
50
+ except TypeError:
51
+ pass
52
+ if hasattr(value, "_get_sim_replacement_") and isinstance(value._get_sim_replacement_, MethodType):
53
+ return value._get_sim_replacement_()
54
+ return _MISSING
55
+
56
+ def _substitute_module_variables(self, module) -> None:
57
+ if not isinstance(module, ModuleType):
58
+ return
59
+
60
+ module_name = module.__name__
61
+
62
+ if module_name in self._original_values:
63
+ return
64
+
65
+ original_values = {}
66
+
67
+ for var_name, var_value in list(module.__dict__.items()):
68
+ replacement = self._get_replacement(var_value)
69
+ if replacement is not _MISSING:
70
+ original_values[var_name] = var_value
71
+ setattr(module, var_name, replacement)
72
+
73
+ if original_values:
74
+ self._original_values[module_name] = original_values
75
+
76
+ def _update_loaded_modules(self) -> None:
77
+ loaded_modules = list(sys.modules.values())
78
+
79
+ for module in loaded_modules:
80
+ if module is not None:
81
+ self._substitute_module_variables(module)
82
+
83
+ def _create_import_hook(self):
84
+ original_import = builtins.__import__
85
+
86
+ def hooked_import(name, globals=None, locals=None, fromlist=(), level=0): # noqa: A002
87
+ module = original_import(name, globals, locals, fromlist, level)
88
+ if isinstance(module, ModuleType):
89
+ self._substitute_module_variables(module)
90
+
91
+ if fromlist:
92
+ for item in fromlist:
93
+ if hasattr(module, item):
94
+ attr = getattr(module, item)
95
+ if isinstance(attr, ModuleType):
96
+ self._substitute_module_variables(attr)
97
+
98
+ return module
99
+
100
+ return hooked_import
101
+
102
+ def __enter__(self) -> Self:
103
+ self._update_loaded_modules()
104
+
105
+ self._original_import = builtins.__import__
106
+ builtins.__import__ = self._create_import_hook()
107
+
108
+ return self
109
+
110
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any):
111
+ try:
112
+ if self._original_import is not None:
113
+ builtins.__import__ = self._original_import
114
+ self._original_import = None
115
+
116
+ for module_name, original_values in self._original_values.items():
117
+ if module_name in sys.modules:
118
+ module = sys.modules[module_name]
119
+ if isinstance(module, ModuleType):
120
+ for var_name, original_value in original_values.items():
121
+ setattr(module, var_name, original_value)
122
+
123
+ self._original_values.clear()
124
+
125
+ finally:
126
+ SimulationContext._active_context = None
127
+
128
+
129
+ def sim_ctx() -> SimulationContext | None:
130
+ """Get the current simulation context, or None if not active."""
131
+ return SimulationContext._active_context
@@ -21,8 +21,10 @@ class TupleImpl(TransientValue):
21
21
  raise TypeError(f"Cannot index tuple with {item}")
22
22
  if int(item) != item:
23
23
  raise TypeError(f"Cannot index tuple with non-integer {item}")
24
- if not (0 <= item < len(self.value)):
24
+ if not (-len(self.value) <= item < len(self.value)):
25
25
  raise IndexError(f"Tuple index out of range: {item}")
26
+ if item < 0:
27
+ item += len(self.value)
26
28
  return self.value[int(item)]
27
29
 
28
30
  @meta_fn
@@ -30,7 +32,7 @@ class TupleImpl(TransientValue):
30
32
  return len(self.value)
31
33
 
32
34
  def __eq__(self, other):
33
- if not isinstance(other, tuple):
35
+ if not self._is_tuple_impl(other):
34
36
  return False
35
37
  if len(self) != len(other):
36
38
  return False
@@ -40,7 +42,7 @@ class TupleImpl(TransientValue):
40
42
  return True
41
43
 
42
44
  def __ne__(self, other):
43
- if not isinstance(other, tuple):
45
+ if not self._is_tuple_impl(other):
44
46
  return True
45
47
  if len(self) != len(other):
46
48
  return True
@@ -50,33 +52,33 @@ class TupleImpl(TransientValue):
50
52
  return False
51
53
 
52
54
  def __lt__(self, other):
53
- if not isinstance(other, tuple):
55
+ if not self._is_tuple_impl(other):
54
56
  return NotImplemented
55
- for a, b in zip(self.value, other.value):
57
+ for a, b in zip(self, other):
56
58
  if a != b:
57
59
  return a < b
58
60
  return len(self.value) < len(other.value)
59
61
 
60
62
  def __le__(self, other):
61
- if not isinstance(other, tuple):
63
+ if not self._is_tuple_impl(other):
62
64
  return NotImplemented
63
- for a, b in zip(self.value, other.value):
65
+ for a, b in zip(self, other):
64
66
  if a != b:
65
67
  return a < b
66
68
  return len(self.value) <= len(other.value)
67
69
 
68
70
  def __gt__(self, other):
69
- if not isinstance(other, tuple):
71
+ if not self._is_tuple_impl(other):
70
72
  return NotImplemented
71
- for a, b in zip(self.value, other.value):
73
+ for a, b in zip(self, other):
72
74
  if a != b:
73
75
  return a > b
74
76
  return len(self.value) > len(other.value)
75
77
 
76
78
  def __ge__(self, other):
77
- if not isinstance(other, tuple):
79
+ if not self._is_tuple_impl(other):
78
80
  return NotImplemented
79
- for a, b in zip(self.value, other.value):
81
+ for a, b in zip(self, other):
80
82
  if a != b:
81
83
  return a > b
82
84
  return len(self.value) >= len(other.value)
@@ -89,6 +91,11 @@ class TupleImpl(TransientValue):
89
91
  other = TupleImpl._accept_(other)
90
92
  return TupleImpl._accept_(self.value + other.value)
91
93
 
94
+ @staticmethod
95
+ @meta_fn
96
+ def _is_tuple_impl(value: Any) -> bool:
97
+ return isinstance(value, TupleImpl)
98
+
92
99
  @classmethod
93
100
  def _accepts_(cls, value: Any) -> bool:
94
101
  return isinstance(value, cls | tuple)
@@ -1,8 +1,10 @@
1
1
  from typing import Self
2
2
 
3
3
  from sonolus.backend.ops import Op
4
+ from sonolus.script.array_like import ArrayLike
4
5
  from sonolus.script.debug import static_error
5
6
  from sonolus.script.internal.native import native_function
7
+ from sonolus.script.internal.range import range_or_tuple
6
8
  from sonolus.script.num import Num
7
9
  from sonolus.script.record import Record
8
10
 
@@ -296,7 +298,7 @@ def unlerp_clamped(a: float, b: float, x: float, /) -> float:
296
298
 
297
299
  @native_function(Op.Remap)
298
300
  def remap(a: float, b: float, c: float, d: float, x: float, /) -> float:
299
- """Remap a value from one interval to another.
301
+ """Linearly remap a value from one interval to another.
300
302
 
301
303
  Args:
302
304
  a: The start of the input interval.
@@ -313,7 +315,7 @@ def remap(a: float, b: float, c: float, d: float, x: float, /) -> float:
313
315
 
314
316
  @native_function(Op.RemapClamped)
315
317
  def remap_clamped(a: float, b: float, c: float, d: float, x: float, /) -> float:
316
- """Remap a value from one interval to another, clamped to the output interval.
318
+ """Linearly remap a value from one interval to another, clamped to the output interval.
317
319
 
318
320
  Args:
319
321
  a: The start of the input interval.
@@ -341,3 +343,59 @@ def clamp(x: float, a: float, b: float, /) -> float:
341
343
  The clamped value.
342
344
  """
343
345
  return max(a, min(b, x))
346
+
347
+
348
+ def interp(
349
+ xp: ArrayLike[float] | tuple[float, ...],
350
+ fp: ArrayLike[float] | tuple[float, ...],
351
+ x: float,
352
+ ) -> float:
353
+ """Linearly interpolate a value within a sequence of points.
354
+
355
+ The sequence must have at least 2 elements and be sorted in increasing order of x-coordinates.
356
+ For values of x outside the range of xp, the slope of the first or last segment is used to extrapolate.
357
+
358
+ Args:
359
+ xp: The x-coordinates of the points in increasing order.
360
+ fp: The y-coordinates of the points.
361
+ x: The x-coordinate to interpolate.
362
+
363
+ Returns:
364
+ The interpolated value.
365
+ """
366
+ assert len(xp) == len(fp)
367
+ assert len(xp) >= 2
368
+ for i in range_or_tuple(1, len(xp) - 1):
369
+ # At i == 1, x may be less than x[0], but since we're extrapolating, we use the first segment regardless.
370
+ if x <= xp[i]:
371
+ return remap(xp[i - 1], xp[i], fp[i - 1], fp[i], x)
372
+ # x > xp[-2] so we can just use the last segment regardless of whether x is in it or to the right of it.
373
+ return remap(xp[-2], xp[-1], fp[-2], fp[-1], x)
374
+
375
+
376
+ def interp_clamped(
377
+ xp: ArrayLike[float] | tuple[float, ...],
378
+ fp: ArrayLike[float] | tuple[float, ...],
379
+ x: float,
380
+ ):
381
+ """Linearly interpolate a value within a sequence of points.
382
+
383
+ The sequence must have at least 2 elements and be sorted in increasing order of x-coordinates.
384
+ For x-coordinates outside the range of the sequence, the respective endpoint of fp is returned.
385
+
386
+ Args:
387
+ xp: The x-coordinates of the points in increasing order.
388
+ fp: The y-coordinates of the points.
389
+ x: The x-coordinate to interpolate.
390
+
391
+ Returns:
392
+ The interpolated value.
393
+ """
394
+ assert len(xp) == len(fp)
395
+ assert len(xp) >= 2
396
+ if x <= xp[0]:
397
+ return fp[0]
398
+ for i in range_or_tuple(1, len(xp)):
399
+ if x <= xp[i]:
400
+ return remap(xp[i - 1], xp[i], fp[i - 1], fp[i], x)
401
+ return fp[-1]
@@ -1,14 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import abstractmethod
4
- from collections.abc import Iterator
5
4
  from typing import Any
6
5
 
7
6
  from sonolus.script.internal.impl import meta_fn
8
7
  from sonolus.script.record import Record
9
8
 
10
9
 
11
- class SonolusIterator[T](Iterator[T]):
10
+ class SonolusIterator[T]:
12
11
  """Base class for Sonolus iterators.
13
12
 
14
13
  This class is used to define custom iterators that can be used in Sonolus.py.
@@ -30,6 +29,8 @@ class SonolusIterator[T](Iterator[T]):
30
29
  ```
31
30
  """
32
31
 
32
+ _allow_instance_check_ = True
33
+
33
34
  def next(self) -> T:
34
35
  result = self.get()
35
36
  self.advance()
sonolus/script/num.py CHANGED
@@ -1,4 +1,3 @@
1
- # ruff: noqa: N801
2
1
  from __future__ import annotations
3
2
 
4
3
  import operator
@@ -81,12 +80,22 @@ class _Num(Value, metaclass=_NumMeta):
81
80
  return value
82
81
  return cls(value)
83
82
 
83
+ def _is_rom_constant(self) -> bool:
84
+ return (
85
+ ctx()
86
+ and isinstance(self.data, BlockPlace)
87
+ and self.data.block == ctx().blocks.EngineRom
88
+ and isinstance(self.data.index, int)
89
+ )
90
+
84
91
  def _is_py_(self) -> bool:
85
- return isinstance(self.data, float | int | bool)
92
+ return isinstance(self.data, float | int | bool) or self._is_rom_constant()
86
93
 
87
94
  def _as_py_(self) -> Any:
88
95
  if not self._is_py_():
89
96
  raise ValueError("Not a compile time constant Num")
97
+ if self._is_rom_constant():
98
+ return ctx().rom.get_value(self.data.index + self.data.offset)
90
99
  if self.data.is_integer():
91
100
  return int(self.data)
92
101
  return self.data
sonolus/script/options.py CHANGED
@@ -5,11 +5,13 @@ from typing import Annotated, Any, NewType, dataclass_transform, get_origin
5
5
  from sonolus.backend.mode import Mode
6
6
  from sonolus.backend.place import BlockPlace
7
7
  from sonolus.script.debug import assert_unreachable
8
- from sonolus.script.internal.context import ctx
8
+ from sonolus.script.internal.context import ctx, debug_config
9
9
  from sonolus.script.internal.descriptor import SonolusDescriptor
10
10
  from sonolus.script.internal.generic import validate_concrete_type
11
11
  from sonolus.script.internal.introspection import get_field_specifiers
12
+ from sonolus.script.internal.simulation_context import sim_ctx
12
13
  from sonolus.script.num import Num
14
+ from sonolus.script.values import copy
13
15
 
14
16
 
15
17
  @dataclass
@@ -186,6 +188,8 @@ class _OptionField(SonolusDescriptor):
186
188
  self.index = index
187
189
 
188
190
  def __get__(self, instance, owner):
191
+ if sim_ctx():
192
+ return sim_ctx().get_or_put_value((instance, self), lambda: copy(self.info.default))
189
193
  if ctx():
190
194
  match ctx().global_state.mode:
191
195
  case Mode.PLAY:
@@ -202,8 +206,27 @@ class _OptionField(SonolusDescriptor):
202
206
  return Num._from_place_(BlockPlace(block, self.index))
203
207
  else:
204
208
  return Num._accept_(self.info.default)
209
+ raise RuntimeError("Options can only be accessed in a context")
205
210
 
206
211
  def __set__(self, instance, value):
212
+ if sim_ctx():
213
+ return sim_ctx().set_or_put_value((instance, self), lambda: copy(self.info.default), value)
214
+ if ctx() and debug_config().unchecked_writes:
215
+ match ctx().global_state.mode:
216
+ case Mode.PLAY:
217
+ block = ctx().blocks.LevelOption
218
+ case Mode.WATCH:
219
+ block = ctx().blocks.LevelOption
220
+ case Mode.PREVIEW:
221
+ block = ctx().blocks.PreviewOption
222
+ case Mode.TUTORIAL:
223
+ block = None
224
+ case _:
225
+ assert_unreachable()
226
+ if block is not None:
227
+ Num._from_place_(BlockPlace(block, self.index))._set_(Num._accept_(value))
228
+ else:
229
+ raise RuntimeError("Options in the current mode cannot be set and use the default value")
207
230
  raise AttributeError("Options are read-only")
208
231
 
209
232
 
sonolus/script/quad.py CHANGED
@@ -28,6 +28,20 @@ class Quad(Record):
28
28
  br: Vec2
29
29
  """The bottom-right corner of the quad."""
30
30
 
31
+ @classmethod
32
+ def zero(cls) -> Quad:
33
+ """Return a quad with all corners set to (0, 0).
34
+
35
+ Returns:
36
+ A new quad with all corners at the origin.
37
+ """
38
+ return cls(
39
+ bl=Vec2.zero(),
40
+ tl=Vec2.zero(),
41
+ tr=Vec2.zero(),
42
+ br=Vec2.zero(),
43
+ )
44
+
31
45
  @classmethod
32
46
  def from_quad(cls, value: QuadLike, /) -> Quad:
33
47
  """Create a quad from a quad-like value."""
@@ -80,7 +94,14 @@ class Quad(Record):
80
94
  ).translate(self.center * (Vec2(1, 1) - factor))
81
95
 
82
96
  def rotate(self, angle: float, /) -> Self:
83
- """Rotate the quad by the given angle about the origin and return a new quad."""
97
+ """Rotate the quad by the given angle about the origin and return a new quad.
98
+
99
+ Args:
100
+ angle: The angle of rotation in radians. Positive angles rotate counterclockwise.
101
+
102
+ Returns:
103
+ A new quad rotated by the given angle.
104
+ """
84
105
  return Quad(
85
106
  bl=self.bl.rotate(angle),
86
107
  tl=self.tl.rotate(angle),
@@ -94,7 +115,15 @@ class Quad(Record):
94
115
  /,
95
116
  pivot: Vec2,
96
117
  ) -> Self:
97
- """Rotate the quad by the given angle about the given pivot and return a new quad."""
118
+ """Rotate the quad by the given angle about the given pivot and return a new quad.
119
+
120
+ Args:
121
+ angle: The angle of rotation in radians. Positive angles rotate counterclockwise.
122
+ pivot: The pivot point for rotation.
123
+
124
+ Returns:
125
+ A new quad rotated about the pivot by the given angle.
126
+ """
98
127
  return Quad(
99
128
  bl=self.bl.rotate_about(angle, pivot),
100
129
  tl=self.tl.rotate_about(angle, pivot),
@@ -103,7 +132,14 @@ class Quad(Record):
103
132
  )
104
133
 
105
134
  def rotate_centered(self, angle: float, /) -> Self:
106
- """Rotate the quad by the given angle about its center and return a new quad."""
135
+ """Rotate the quad by the given angle about its center and return a new quad.
136
+
137
+ Args:
138
+ angle: The angle of rotation in radians. Positive angles rotate counterclockwise.
139
+
140
+ Returns:
141
+ A new quad rotated about its center by the given angle.
142
+ """
107
143
  return self.rotate_about(angle, self.center)
108
144
 
109
145
  def permute(self, count: int = 1, /) -> Self:
@@ -147,6 +183,8 @@ class Quad(Record):
147
183
  def contains_point(self, point: Vec2, /) -> bool:
148
184
  """Check if the quad contains the given point.
149
185
 
186
+ It is not guaranteed whether points on the edges of the quad are considered inside or outside.
187
+
150
188
  Args:
151
189
  point: The point to check.
152
190
 
sonolus/script/record.py CHANGED
@@ -20,8 +20,15 @@ from sonolus.script.internal.value import BackingSource, DataValue, Value
20
20
  from sonolus.script.num import Num
21
21
 
22
22
 
23
+ class RecordMeta(type):
24
+ @meta_fn
25
+ def __pos__[T](cls: type[T]) -> T:
26
+ """Create a zero-initialized record instance."""
27
+ return cls._zero_()
28
+
29
+
23
30
  @dataclass_transform(eq_default=True)
24
- class Record(GenericValue):
31
+ class Record(GenericValue, metaclass=RecordMeta):
25
32
  """Base class for user-defined data structures.
26
33
 
27
34
  Usage:
@@ -38,6 +45,15 @@ class Record(GenericValue):
38
45
  field1: T
39
46
  field2: U
40
47
  ```
48
+
49
+ Creating an instance:
50
+ ```python
51
+ record = MyRecord(field1=42, field2=True)
52
+ record_2 = MyGenericRecord[int, int](field1=42, field2=100)
53
+ record_3 = MyGenericRecord(field1=42, field2=100) # Type arguments can be inferred
54
+ record_4 = +MyRecord # Create a zero-initialized record
55
+ record_5 = +MyGenericRecord[int, int]
56
+ ```
41
57
  """
42
58
 
43
59
  _value: dict[str, Value]
@@ -239,12 +255,12 @@ class Record(GenericValue):
239
255
 
240
256
  def __str__(self):
241
257
  return (
242
- f"{self.__class__.__name__}({", ".join(f"{field.name}={field.__get__(self)}" for field in self._fields)})"
258
+ f"{self.__class__.__name__}({', '.join(f'{field.name}={field.__get__(self)}' for field in self._fields)})"
243
259
  )
244
260
 
245
261
  def __repr__(self):
246
262
  return (
247
- f"{self.__class__.__name__}({", ".join(f"{field.name}={field.__get__(self)!r}" for field in self._fields)})"
263
+ f"{self.__class__.__name__}({', '.join(f'{field.name}={field.__get__(self)!r}' for field in self._fields)})"
248
264
  )
249
265
 
250
266
  @meta_fn
@@ -268,6 +284,11 @@ class Record(GenericValue):
268
284
  def __hash__(self):
269
285
  return hash(tuple(field.__get__(self) for field in self._fields))
270
286
 
287
+ @meta_fn
288
+ def __pos__(self) -> Self:
289
+ """Return a copy of the record."""
290
+ return self._copy_()
291
+
271
292
  @classmethod
272
293
  @meta_fn
273
294
  def type_var_value(cls, var: TypeVar, /) -> Any: