424 lines
16 KiB
Python
424 lines
16 KiB
Python
|
|
"""Art-Net-Fixture-Engine: Master32- und Layer64-Dekodierung
|
|||
|
|
(PLAN.md §16.3, §16.4, §16.5, §16.6).
|
|||
|
|
|
|||
|
|
Dekodiert DMX-Kanäle in strukturale Steuerdaten und Emitting von
|
|||
|
|
Trigger-Ereignissen (Flanken). Rein datenverarbeitend; alle Ergebnisse
|
|||
|
|
fließen über die Parameter-Engine (§11), nie direkt in den Renderer.
|
|||
|
|
|
|||
|
|
Fixtures:
|
|||
|
|
- HMS MediaEngine Master 32ch (§16.3)
|
|||
|
|
- HMS MediaEngine Layer 64ch (§16.4): ein Layer-Fixture = exakt 64 Kanäle,
|
|||
|
|
8 Layer = exakt 1 Universe (§16.2)
|
|||
|
|
|
|||
|
|
Verhalten:
|
|||
|
|
- 16-Bit: MSB zuerst (DMX-Konvention)
|
|||
|
|
- Trigger: steigende Flanke über Schwelle, kein Dauerzustand (§16.3/§16.5)
|
|||
|
|
- Load/Commit (§16.5): Bank/Folder/Index-Änderungen erzeugen pending
|
|||
|
|
selection; erst Flanke auf Load/Commit löst Preload+Umschaltung aus;
|
|||
|
|
optional Auto-Load mit Debounce (V1: manuell)
|
|||
|
|
- Pickup/Takeover (§16.5): Wertänderungen nach Plugin-/Quellwechsel werden
|
|||
|
|
erst nach neuem Load/Commit interpretiert – keine Parametersprünge
|
|||
|
|
- Speed-Mapping (§16.6): signed 16-Bit; Mittelpunkt (32768) = Pause (0),
|
|||
|
|
untere Hälfte −4x bis 0, obere Hälfte 0 bis +4x, definierter Wert 40960
|
|||
|
|
entspricht exakt 1×
|
|||
|
|
- reservierte Kanäle: neutral ignorieren (§16.3/§16.4), keine Fehler
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from enum import StrEnum
|
|||
|
|
|
|||
|
|
# ---------- Master32 (§16.3) ----------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class MasterEvent:
|
|||
|
|
"""Ereignis des Master-Fixtures (nur bei Flanken)."""
|
|||
|
|
|
|||
|
|
kind: str # preset_recall | tap_tempo | release_overrides
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class MasterControl:
|
|||
|
|
"""Dekodierte Master32-Steuerdaten (§16.3)."""
|
|||
|
|
|
|||
|
|
master_intensity: float = 1.0 # 16 Bit Kanal 1-2 → 0..1
|
|||
|
|
blackout: bool = False # Kanal 3: >=128, höchste Priorität
|
|||
|
|
freeze_output: bool = False # Kanal 4
|
|||
|
|
preset_bank: int = 0 # Kanal 5
|
|||
|
|
preset_index: int = 0 # 16 Bit Kanal 6-7
|
|||
|
|
transition_type: int = 0 # Kanal 9: Enum
|
|||
|
|
transition_duration: float = 0.0 # 16 Bit Kanal 10-11 → 0..1 (Max konfiguriert)
|
|||
|
|
global_speed: float = 1.0 # 16 Bit Kanal 12-13 (Speed-Mapping §16.6)
|
|||
|
|
bpm: float = 120.0 # 16 Bit Kanal 14-15 → 20..300
|
|||
|
|
audio_reactive: bool = False # Kanal 22
|
|||
|
|
audio_gain: float = 1.0 # Kanal 23 → 0..2
|
|||
|
|
automation_ai_enable: bool = False # Kanal 24: nur Freigabe
|
|||
|
|
test_pattern: int = 0 # Kanal 25: Enum, 0 = aus
|
|||
|
|
preview_enable: bool = False # Kanal 26
|
|||
|
|
global_hue: float = 0.0 # Kanal 27 → -0.5..0.5
|
|||
|
|
global_saturation: float = 1.0 # Kanal 28 → 0..2
|
|||
|
|
fallback_preset: int = 0 # Kanal 29
|
|||
|
|
events: list[MasterEvent] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Master32Engine:
|
|||
|
|
"""Dekodiert ein 32-Kanal-DMX-Segment in MasterControl.
|
|||
|
|
|
|||
|
|
Kanäle 17–21 und 31–32 sind reserviert und werden neutral ignoriert
|
|||
|
|
(§16.3). Trigger-Kanäle (8 Preset Recall, 16 Tap Tempo, 30 Release)
|
|||
|
|
emittieren Flanken-Ereignisse, gehaltene Werte nicht.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
TRIGGER_THRESHOLD = 128
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
self._prev: bytearray | None = None # letzter Kanalstand für Flanken
|
|||
|
|
|
|||
|
|
def decode(self, channels: bytes) -> MasterControl:
|
|||
|
|
"""Dekodiert exakt 32 Kanäle; reservierte neutral ignorierend."""
|
|||
|
|
if len(channels) < 32:
|
|||
|
|
raise ValueError(f"Master32 benötigt 32 Kanäle, erhalten {len(channels)}")
|
|||
|
|
prev = self._prev
|
|||
|
|
curr = bytearray(channels[:32])
|
|||
|
|
events: list[MasterEvent] = []
|
|||
|
|
threshold = self.TRIGGER_THRESHOLD
|
|||
|
|
|
|||
|
|
def _rising(channel_index: int) -> bool:
|
|||
|
|
"""Steigende Flanke: jetzt über Schwelle, vorher darunter."""
|
|||
|
|
now_active = channels[channel_index] >= threshold
|
|||
|
|
before_active = bool(prev and prev[channel_index] >= threshold)
|
|||
|
|
return now_active and not before_active
|
|||
|
|
|
|||
|
|
if _rising(7): # Kanal 8: Preset Recall (§16.3: direkter Abruf)
|
|||
|
|
events.append(MasterEvent(kind="preset_recall"))
|
|||
|
|
if _rising(15): # Kanal 16: Tap Tempo
|
|||
|
|
events.append(MasterEvent(kind="tap_tempo"))
|
|||
|
|
if _rising(29): # Kanal 30: Release Manual Overrides (Schutzlogik §16.3)
|
|||
|
|
events.append(MasterEvent(kind="release_overrides"))
|
|||
|
|
self._prev = curr
|
|||
|
|
|
|||
|
|
speed_16 = decode_u16(channels, 11) # Kanal 12-13 (0-basiert 11-12)
|
|||
|
|
bpm_16 = decode_u16(channels, 13) # Kanal 14-15
|
|||
|
|
return MasterControl(
|
|||
|
|
master_intensity=decode_u16(channels, 0) / 65535.0,
|
|||
|
|
blackout=channels[2] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
freeze_output=channels[3] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
preset_bank=channels[4],
|
|||
|
|
preset_index=decode_u16(channels, 5),
|
|||
|
|
transition_type=channels[8],
|
|||
|
|
transition_duration=decode_u16(channels, 9) / 65535.0,
|
|||
|
|
global_speed=map_speed(speed_16),
|
|||
|
|
bpm=20.0 + (bpm_16 / 65535.0) * 280.0, # 20..300 BPM
|
|||
|
|
audio_reactive=channels[21] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
audio_gain=channels[22] / 255.0 * 2.0,
|
|||
|
|
automation_ai_enable=channels[23] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
test_pattern=channels[24],
|
|||
|
|
preview_enable=channels[25] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
global_hue=(channels[26] / 255.0) - 0.5,
|
|||
|
|
global_saturation=channels[27] / 255.0 * 2.0,
|
|||
|
|
fallback_preset=channels[28],
|
|||
|
|
events=events,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------- Layer64 (§16.4) ----------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SourceType(StrEnum):
|
|||
|
|
"""Kanal 4: Source Type (§16.4)."""
|
|||
|
|
|
|||
|
|
MEDIA = "media"
|
|||
|
|
GENERATOR = "generator"
|
|||
|
|
SOLID = "solid"
|
|||
|
|
LIVE = "live" # später: Capture (§16.4)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TransportCommand(StrEnum):
|
|||
|
|
"""Kanal 10: Transport (§16.4)."""
|
|||
|
|
|
|||
|
|
STOP = "stop"
|
|||
|
|
PLAY = "play"
|
|||
|
|
PAUSE = "pause"
|
|||
|
|
RETRIGGER = "retrigger"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class LayerEvent:
|
|||
|
|
"""Ereignisse des Layer-Fixtures (nur Flanken, §16.5)."""
|
|||
|
|
|
|||
|
|
kind: str # load_commit | retrigger
|
|||
|
|
pending_bank: int = 0
|
|||
|
|
pending_folder: int = 0
|
|||
|
|
pending_index: int = 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class LayerControl:
|
|||
|
|
"""Dekodierte Layer64-Steuerdaten (§16.4).
|
|||
|
|
|
|||
|
|
source_block (Kanal 13-20) ist modusabhängig: bei Media =
|
|||
|
|
Speed/Position/In/Out; bei Generator/Solid = G1..G8 (§16.4).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
enabled: bool = True # Kanal 1
|
|||
|
|
opacity: float = 1.0 # 16 Bit Kanal 2-3
|
|||
|
|
source_type: SourceType = SourceType.MEDIA # Kanal 4
|
|||
|
|
media_bank: int = 0 # Kanal 5
|
|||
|
|
media_folder: int = 0 # Kanal 6
|
|||
|
|
media_index: int = 0 # 16 Bit Kanal 7-8 (pending bis Load)
|
|||
|
|
transport: TransportCommand = TransportCommand.STOP # Kanal 10
|
|||
|
|
loop_mode: int = 0 # Kanal 11: Enum
|
|||
|
|
playback_direction: int = 0 # Kanal 12: Enum
|
|||
|
|
speed: float = 1.0 # Kanal 13-14: signed Speed-Mapping (§16.6), Media-Modus
|
|||
|
|
position: float = 0.0 # Kanal 15-16 → 0..1, Media-Modus
|
|||
|
|
in_point: float = 0.0 # Kanal 17-18 → 0..1, Media-Modus
|
|||
|
|
out_point: float = 1.0 # Kanal 19-20 → 0..1, Media-Modus
|
|||
|
|
generator_params: tuple[float, ...] = () # G1..G8 (0..1), Generator/Solid
|
|||
|
|
blend_mode: int = 0 # Kanal 21: Enum
|
|||
|
|
position_x: float = 0.0 # 16 Bit signed Kanal 23-24 → -1..1
|
|||
|
|
position_y: float = 0.0 # Kanal 25-26
|
|||
|
|
scale_x: float = 1.0 # 16 Bit Kanal 27-28 → 0..4
|
|||
|
|
scale_y: float = 1.0 # Kanal 29-30
|
|||
|
|
rotation_deg: float = 0.0 # 16 Bit Kanal 31-32 → 0..360
|
|||
|
|
crop_left: float = 0.0 # Kanal 33-36 → 0..1
|
|||
|
|
crop_right: float = 0.0
|
|||
|
|
crop_top: float = 0.0
|
|||
|
|
crop_bottom: float = 0.0
|
|||
|
|
hue: float = 0.0 # Kanal 37-40 → Reglerbereiche
|
|||
|
|
saturation: float = 1.0
|
|||
|
|
brightness: float = 1.0
|
|||
|
|
contrast: float = 1.0
|
|||
|
|
fx1_enabled: bool = False # Kanal 41
|
|||
|
|
fx1_plugin: int = 0 # Kanal 42: Show-Registry
|
|||
|
|
fx1_mix: float = 1.0 # Kanal 43 → 0..1
|
|||
|
|
fx1_params: tuple[float, ...] = () # Kanal 44-51: P1-P8
|
|||
|
|
fx2_enabled: bool = False # Kanal 52
|
|||
|
|
fx2_plugin: int = 0 # Kanal 53
|
|||
|
|
fx2_mix: float = 1.0 # Kanal 54
|
|||
|
|
fx2_params: tuple[float, ...] = () # Kanal 55-62: P1-P8
|
|||
|
|
events: list[LayerEvent] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def decode_u16(channels: bytes, msb_index: int) -> int:
|
|||
|
|
"""16-Bit: MSB zuerst (DMX-Konvention)."""
|
|||
|
|
return (channels[msb_index] << 8) | channels[msb_index + 1]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def decode_s16_normalized(channels: bytes, msb_index: int) -> float:
|
|||
|
|
"""16-Bit signed → -1..1 (Position, §16.4)."""
|
|||
|
|
raw = decode_u16(channels, msb_index)
|
|||
|
|
return (raw / 32768.0) - 1.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def map_speed(raw_u16: int) -> float:
|
|||
|
|
"""Speed-Mapping nach §16.6 (signed 16-Bit):
|
|||
|
|
|
|||
|
|
- untere Hälfte (0..32767): −4× (bei 0) bis 0− (Richtung Mittelpunkt)
|
|||
|
|
- Mittelpunkt (32768): Pause (0.0)
|
|||
|
|
- obere Hälfte (32769..65535): 0+ bis +4× (Max konfiguriert)
|
|||
|
|
- definierter Referenzwert 40960 entspricht exakt +1×
|
|||
|
|
(denn (40960−32768)/32768 × 4 = 1.0; dokumentiert in fixtures und
|
|||
|
|
Fixture-Handbuch)
|
|||
|
|
- Totzone um Pause optional (hier nicht implementiert, §16.6)
|
|||
|
|
"""
|
|||
|
|
half = 32768
|
|||
|
|
if raw_u16 == half:
|
|||
|
|
return 0.0 # Mittelpunkt = Pause (§16.6)
|
|||
|
|
if raw_u16 < half:
|
|||
|
|
# untere Hälfte: 0 → −4x, Richtung 32768 → 0− (monoton steigend)
|
|||
|
|
return -(1.0 - raw_u16 / half) * 4.0
|
|||
|
|
# obere Hälfte: 32769 → 0+, Richtung 65535 → +4x (monoton steigend)
|
|||
|
|
return ((raw_u16 - half) / half) * 4.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Layer64Engine:
|
|||
|
|
"""Dekodiert ein 64-Kanal-DMX-Segment in LayerControl (§16.4/§16.5).
|
|||
|
|
|
|||
|
|
Load/Commit (§16.5):
|
|||
|
|
- Änderungen an Bank/Folder/Index/SourceType erzeugen nur eine pending
|
|||
|
|
selection (kein direktes Laden)
|
|||
|
|
- erst eine steigende Flanke auf Kanal 9 (Load/Commit) emittiert ein
|
|||
|
|
load_commit-Ereignis mit der vollständigen Auswahl
|
|||
|
|
- Pickup/Takeover: nach Load/Commit gilt die Auswahl als bestätigt;
|
|||
|
|
Parameteränderungen wirken sofort (keine Sprünge, da Werte erst
|
|||
|
|
nach Commit neu interpretiert werden)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
TRIGGER_THRESHOLD = 128
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
self._prev: bytearray | None = None
|
|||
|
|
self._pending_loaded = True # beim Start: keine pending selection
|
|||
|
|
|
|||
|
|
def decode(self, channels: bytes) -> LayerControl:
|
|||
|
|
if len(channels) < 64:
|
|||
|
|
raise ValueError(f"Layer64 benötigt 64 Kanäle, erhalten {len(channels)}")
|
|||
|
|
prev = self._prev
|
|||
|
|
curr = bytearray(channels[:64])
|
|||
|
|
events: list[LayerEvent] = []
|
|||
|
|
|
|||
|
|
def _rising(idx: int) -> bool:
|
|||
|
|
now = channels[idx] >= self.TRIGGER_THRESHOLD
|
|||
|
|
before = bool(prev and prev[idx] >= self.TRIGGER_THRESHOLD)
|
|||
|
|
return now and not before
|
|||
|
|
|
|||
|
|
source_type = _source_type_of(channels[3])
|
|||
|
|
# pending selection (§16.5): Auswahl ändert sich, laden erst bei Flanke
|
|||
|
|
pending_changed = (
|
|||
|
|
prev is None
|
|||
|
|
or curr[4] != prev[4]
|
|||
|
|
or curr[5] != prev[5]
|
|||
|
|
or decode_u16(curr, 6) != decode_u16(prev, 6)
|
|||
|
|
or curr[3] != prev[3]
|
|||
|
|
)
|
|||
|
|
if pending_changed:
|
|||
|
|
self._pending_loaded = False
|
|||
|
|
|
|||
|
|
if _rising(8): # Kanal 9: Load/Commit Selection
|
|||
|
|
events.append(
|
|||
|
|
LayerEvent(
|
|||
|
|
kind="load_commit",
|
|||
|
|
pending_bank=channels[4],
|
|||
|
|
pending_folder=channels[5],
|
|||
|
|
pending_index=decode_u16(channels, 6),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
self._pending_loaded = True
|
|||
|
|
if _rising(62): # Kanal 63: Layer Retrigger/Reset
|
|||
|
|
events.append(LayerEvent(kind="retrigger"))
|
|||
|
|
self._prev = curr
|
|||
|
|
|
|||
|
|
# modusabhängiger Source-Block (Kanäle 13-20, §16.4)
|
|||
|
|
speed = 1.0
|
|||
|
|
position = 0.0
|
|||
|
|
in_point = 0.0
|
|||
|
|
out_point = 1.0
|
|||
|
|
generator_params: tuple[float, ...] = ()
|
|||
|
|
if source_type is SourceType.MEDIA:
|
|||
|
|
speed = map_speed(decode_u16(channels, 12))
|
|||
|
|
position = decode_u16(channels, 14) / 65535.0
|
|||
|
|
in_point = decode_u16(channels, 16) / 65535.0
|
|||
|
|
out_point = decode_u16(channels, 18) / 65535.0
|
|||
|
|
else:
|
|||
|
|
# Generator/Solid/Live: G1..G8 über Kanäle 13-20 (§16.4)
|
|||
|
|
generator_params = tuple(c / 255.0 for c in channels[12:20])
|
|||
|
|
|
|||
|
|
return LayerControl(
|
|||
|
|
enabled=channels[0] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
opacity=decode_u16(channels, 1) / 65535.0,
|
|||
|
|
source_type=source_type,
|
|||
|
|
media_bank=channels[4],
|
|||
|
|
media_folder=channels[5],
|
|||
|
|
media_index=decode_u16(channels, 6),
|
|||
|
|
transport=_transport_of(channels[9]),
|
|||
|
|
loop_mode=channels[10],
|
|||
|
|
playback_direction=channels[11],
|
|||
|
|
speed=speed,
|
|||
|
|
position=position,
|
|||
|
|
in_point=in_point,
|
|||
|
|
out_point=out_point,
|
|||
|
|
generator_params=generator_params,
|
|||
|
|
blend_mode=channels[20],
|
|||
|
|
position_x=decode_s16_normalized(channels, 22),
|
|||
|
|
position_y=decode_s16_normalized(channels, 24),
|
|||
|
|
scale_x=decode_u16(channels, 26) / 65535.0 * 4.0,
|
|||
|
|
scale_y=decode_u16(channels, 28) / 65535.0 * 4.0,
|
|||
|
|
rotation_deg=decode_u16(channels, 30) / 65535.0 * 360.0,
|
|||
|
|
crop_left=channels[32] / 255.0,
|
|||
|
|
crop_right=channels[33] / 255.0,
|
|||
|
|
crop_top=channels[34] / 255.0,
|
|||
|
|
crop_bottom=channels[35] / 255.0,
|
|||
|
|
hue=(channels[36] / 255.0) - 0.5,
|
|||
|
|
saturation=channels[37] / 255.0 * 2.0,
|
|||
|
|
brightness=channels[38] / 255.0 * 2.0,
|
|||
|
|
contrast=channels[39] / 255.0 * 2.0,
|
|||
|
|
fx1_enabled=channels[40] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
fx1_plugin=channels[41],
|
|||
|
|
fx1_mix=channels[42] / 255.0,
|
|||
|
|
fx1_params=tuple(c / 255.0 for c in channels[43:51]),
|
|||
|
|
fx2_enabled=channels[51] >= self.TRIGGER_THRESHOLD,
|
|||
|
|
fx2_plugin=channels[52],
|
|||
|
|
fx2_mix=channels[53] / 255.0,
|
|||
|
|
fx2_params=tuple(c / 255.0 for c in channels[54:62]),
|
|||
|
|
events=events,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def selection_pending(self) -> bool:
|
|||
|
|
"""True, wenn eine Auswahl geändert, aber noch nicht committed wurde."""
|
|||
|
|
return not self._pending_loaded
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _source_type_of(value: int) -> SourceType:
|
|||
|
|
"""Kanal 4: 0=Media, 1=Generator, 2=Solid, 3=Live (§16.4)."""
|
|||
|
|
mapping = {
|
|||
|
|
0: SourceType.MEDIA,
|
|||
|
|
1: SourceType.GENERATOR,
|
|||
|
|
2: SourceType.SOLID,
|
|||
|
|
3: SourceType.LIVE,
|
|||
|
|
}
|
|||
|
|
return mapping.get(value, SourceType.MEDIA)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _transport_of(value: int) -> TransportCommand:
|
|||
|
|
"""Kanal 10: 0=Stop, 1=Play, 2=Pause, 3=Retrigger (§16.4)."""
|
|||
|
|
mapping = {
|
|||
|
|
0: TransportCommand.STOP,
|
|||
|
|
1: TransportCommand.PLAY,
|
|||
|
|
2: TransportCommand.PAUSE,
|
|||
|
|
3: TransportCommand.RETRIGGER,
|
|||
|
|
}
|
|||
|
|
return mapping.get(value, TransportCommand.STOP)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------- Universe-Plan (§16.2 Mehrserver-Patch) ----------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class UniverseCollisionError(Exception):
|
|||
|
|
"""Überlappende Universe-Bereiche zweier Nodes (§16.2: Blocker)."""
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class UniverseRange:
|
|||
|
|
"""Zusammenhängender Universe-Bereich eines Nodes (§16.2)."""
|
|||
|
|
|
|||
|
|
node_id: str
|
|||
|
|
first: int # inklusiv
|
|||
|
|
last: int # inklusiv
|
|||
|
|
|
|||
|
|
|
|||
|
|
class UniversePlan:
|
|||
|
|
"""Zentraler Universe-Plan: kollisionsfreie Bereiche je Node (§16.2).
|
|||
|
|
|
|||
|
|
- add: registriert einen Bereich; Überschneidung = Fehler (Blocker vor
|
|||
|
|
Show-Lock), kein stillsches Zusammenführen
|
|||
|
|
- overlaps: Preflight-Prüfung vor dem Show-Lock
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
self._ranges: list[UniverseRange] = []
|
|||
|
|
|
|||
|
|
def add(self, rng: UniverseRange) -> None:
|
|||
|
|
if rng.first > rng.last:
|
|||
|
|
raise ValueError("first muss <= last sein")
|
|||
|
|
for other in self._ranges:
|
|||
|
|
if self._overlaps(rng, other):
|
|||
|
|
raise UniverseCollisionError(
|
|||
|
|
f"Universe-Kollision: {rng.node_id} [{rng.first}..{rng.last}] "
|
|||
|
|
f"vs {other.node_id} [{other.first}..{other.last}] (§16.2)"
|
|||
|
|
)
|
|||
|
|
self._ranges.append(rng)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _overlaps(a: UniverseRange, b: UniverseRange) -> bool:
|
|||
|
|
return not (a.last < b.first or b.last < a.first)
|
|||
|
|
|
|||
|
|
def overlaps(self, rng: UniverseRange) -> bool:
|
|||
|
|
"""True, wenn der Bereich einen bestehenden schneidet (Preflight)."""
|
|||
|
|
return any(self._overlaps(rng, other) for other in self._ranges)
|
|||
|
|
|
|||
|
|
def ranges(self) -> tuple[UniverseRange, ...]:
|
|||
|
|
return tuple(self._ranges)
|