diff --git a/.gitignore b/.gitignore index f88b2e43..0f1bda7a 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ utils/* # Blender specific .blender_ext/ /notes +nul diff --git a/operators/base_stateful.py b/operators/base_stateful.py index 14e9ad02..d4f3a99d 100644 --- a/operators/base_stateful.py +++ b/operators/base_stateful.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Any import bpy from bpy.types import Context @@ -8,6 +8,7 @@ from ..stateful_operator.integration import StatefulOperator from ..model.types import SlvsGenericEntity, SlvsPoint3D, SlvsPoint2D, SlvsNormal3D from .utilities import get_hovered +from ..serialize import scene_to_dict, scene_from_dict class GenericEntityOp(StatefulOperator): @@ -121,7 +122,7 @@ def state_property(self, state_index): return pointer_name + "_fallback" return "" - def get_state_pointer(self, index=Union[None, int], implicit=False): + def get_state_pointer(self, index: Optional[int] = None, implicit=False): retval = super().get_state_pointer(index=index, implicit=implicit) if retval: return retval @@ -161,7 +162,9 @@ def set_state_pointer(self, values, index=None, implicit=False): state = self.get_states_definition()[index] pointer_name = state.pointer data = self._state_data.get(index, {}) - pointer_type = data["type"] + pointer_type = data.get("type") + if pointer_type is None: + return None if issubclass(pointer_type, SlvsGenericEntity): value = values[0] if values is not None else None @@ -183,3 +186,19 @@ def gather_selection(self, context: Context): selected.extend(list(context.scene.sketcher.entities.selected)) return selected + + def on_before_redo_states(self, context: Context): + global_data.ignore_list.clear() + + def create_snapshot(self, context: Context) -> Any: + """Create a complete snapshot of all sketcher state using serialization""" + # Use the existing serialization system + return scene_to_dict(context.scene) + + def restore_snapshot(self, context: Context, snapshot: Any) -> None: + """Restore sketcher state from serialized snapshot""" + if not snapshot: + return + + # Use the existing deserialization system + scene_from_dict(context.scene, snapshot) diff --git a/stateful_operator/constants.py b/stateful_operator/constants.py index eda18f38..f7dde103 100644 --- a/stateful_operator/constants.py +++ b/stateful_operator/constants.py @@ -18,7 +18,6 @@ "F", "T", "Y", - "U", "R", "E", "G", diff --git a/stateful_operator/integration.py b/stateful_operator/integration.py index a29e4f2f..27ef121a 100644 --- a/stateful_operator/integration.py +++ b/stateful_operator/integration.py @@ -21,7 +21,7 @@ import bpy from bpy.types import Context -from typing import Optional +from typing import Optional, Any class StatefulOperator(StatefulOperatorLogic): @@ -32,6 +32,7 @@ def _has_global_object(cls): states = cls.get_states_definition() return any([s.pointer == "object" for s in states]) + @classmethod def _get_global_object_index(cls): states = cls.get_states_definition() object_in_list = [s.pointer == "object" for s in states] @@ -58,7 +59,7 @@ def register_properties(cls): if pointer_name in annotations.keys(): # Skip pointers that have a property defined # Note: pointer might not need implicit props, thus no need for getter/setter - return + continue if hasattr(cls, pointer_name): # This can happen when the addon is re-enabled in the same session @@ -102,7 +103,10 @@ def get_state_pointer( obj_name = self._state_data[global_ob_index]["object_name"] else: obj_name = data["object_name"] - obj = get_evaluated_obj(bpy.context, bpy.data.objects[obj_name]) + blender_obj = bpy.data.objects.get(obj_name) + if blender_obj is None: + return None + obj = get_evaluated_obj(bpy.context, blender_obj) if pointer_type in mesh_element_types: index = data["mesh_index"] @@ -136,7 +140,9 @@ def set_state_pointer(self, values, index=None, implicit=False): pointer_name = state.pointer data = self._state_data.get(index, {}) - pointer_type = data["type"] + pointer_type = data.get("type") + if pointer_type is None: + return None def get_value(index): if values is None: @@ -210,7 +216,7 @@ def pick_element(self, context: Context, coords): "object_name" ) if global_ob_name: - global_ob = bpy.data.objects[global_ob_name] + global_ob = bpy.data.objects.get(global_ob_name) ob, type, index = get_mesh_element(context, coords, **types, object=global_ob) @@ -235,7 +241,8 @@ def gather_selection(self, context: Context): selected = [] states = self.get_states() types = [] - [types.extend(s.types) for s in states] + for s in states: + types.extend(s.types) # Note: Where to take mesh elements from? Editmode data is only written # when left probably making it impossible to use selected elements in realtime. @@ -250,7 +257,7 @@ def parse_selection(self, context, selected, index=None): # should go through objects, vertices, entities depending on state.types result = None - if not index: + if index is None: index = self.state_index state = self.get_states_definition()[index] data = self.get_state_data(index) @@ -290,3 +297,11 @@ def draw(self, context): if hasattr(self, "draw_settings"): self.draw_settings(context) + + def create_snapshot(self, context: Context): + """Snapshot relevant Blender data references""" + return None + + def restore_snapshot(self, context: Context, snapshot): + """Restore Blender references - mostly validation""" + pass diff --git a/stateful_operator/invoke_op.py b/stateful_operator/invoke_op.py index f3079f9a..14c102a2 100644 --- a/stateful_operator/invoke_op.py +++ b/stateful_operator/invoke_op.py @@ -41,8 +41,17 @@ def execute(self, context: Context): options["wait_for_input"] = True - op_name = self.operator.split(".", 1) - op = getattr(getattr(bpy.ops, op_name[0]), op_name[1]) + parts = self.operator.split(".", 1) + if len(parts) != 2: + self.report({"ERROR"}, f"Invalid operator id '{self.operator}': expected 'module.name'") + return {"CANCELLED"} + + module = getattr(bpy.ops, parts[0], None) + op = getattr(module, parts[1], None) if module is not None else None + if op is None: + self.report({"ERROR"}, f"Operator not found: '{self.operator}'") + return {"CANCELLED"} + if op.poll(): op("INVOKE_DEFAULT", **options) return {"FINISHED"} diff --git a/stateful_operator/logic.py b/stateful_operator/logic.py index 7eef88c9..3e378e27 100644 --- a/stateful_operator/logic.py +++ b/stateful_operator/logic.py @@ -3,81 +3,70 @@ from bpy.types import Context, Event from mathutils import Vector -# TODO: Move to entity extended op -from .. import global_data - +from .state_machine import _StateMachineMixin from .utilities.generic import to_list from .utilities.description import state_desc, stateful_op_desc -from .utilities.keymap import ( - get_key_map_desc, - is_numeric_input, - is_unit_input, - get_unit_value, - get_value_from_event, -) +from .utilities.keymap import get_key_map_desc, is_numeric_input, is_unit_input +from .utilities.numeric import NumericInput + +from typing import Optional, Any + +# Re-export so any `from .logic import _NumericInput` keeps working. +_NumericInput = NumericInput + + +class StatefulOperatorLogic(_StateMachineMixin): + """Stateful operator behaviour: numeric input, modal loop, undo, continuous draw. -from typing import Optional + Inherits the pure state machine from ``_StateMachineMixin``. + Lifecycle (modal path) + ---------------------- + invoke → prefill_state_props (optional) → modal loop: + modal → evaluate_state → [next_state | _end | do_continuous_draw] -class StatefulOperatorLogic: - """Base class which implements the behaviour logic""" + Lifecycle (redo/execute path) + ----------------------------- + execute → redo_states → main → _end + """ state_index: IntProperty(options={"HIDDEN", "SKIP_SAVE"}) wait_for_input: BoolProperty(options={"HIDDEN", "SKIP_SAVE"}, default=True) continuous_draw: BoolProperty(name="Continuous Draw", default=False) executed = False - # Stores the returned value of state_func the first time it runs per state + # Screen coords when a state first runs — used by state_func for delta/scale state_init_coords = None - _state_data = {} _last_coords = Vector((0, 0)) - _numeric_input = {} _undo = False + _state_snapshot = None - def get_property(self, index: Optional[int] = None): - if index is None: - index = self.state_index - state = self.get_states()[index] + # ------------------------------------------------------------------------- + # Snapshot / undo hooks (override in subclasses) + # ------------------------------------------------------------------------- - if state.property is None: - return None + def create_snapshot(self, context: Context) -> Any: + """Return a snapshot of state to restore on cancel/undo. - if callable(state.property): - props = state.property(self, index) - elif state.property: - if callable(getattr(self, state.property)): - props = getattr(self, state.property)(index) - else: - props = state.property - elif hasattr(self, "state_property") and callable(self.state_property): - props = self.state_property(index) - else: - return None - - retval = to_list(props) - return retval - - @classmethod - def get_states_definition(cls): - if callable(cls.states): - return cls.states() - return cls.states + Return ``None`` to fall back to Blender's undo system. + """ + return None - def get_states(self): - if callable(self.states): - return self.states(operator=self) - return self.states + def restore_snapshot(self, context: Context, snapshot: Any) -> None: + """Restore state from a snapshot produced by ``create_snapshot``.""" + pass - @property - def state(self): - return self.get_states()[self.state_index] + def on_before_redo_states(self, context: Context): + """Called before ``redo_states`` during undo/redo cycles. - def _index_from_state(self, state): - return [e.name for e in self.get_states()].index(state) + Override to clear transient state that must be rebuilt + (e.g. entity ignore lists used by draw handlers). + """ + pass - @state.setter - def state(self, state): - self.state_index = self._index_from_state(state) + # ------------------------------------------------------------------------- + # State transitions (depend on numeric + status text — stay here) + # ------------------------------------------------------------------------- def set_state(self, context: Context, index: int): self.state_index = index @@ -93,8 +82,11 @@ def next_state(self, context: Context): self.set_state(context, i + 1) return True + # ------------------------------------------------------------------------- + # Numeric input — delegates to self._numeric (NumericInput) + # ------------------------------------------------------------------------- + def set_status_text(self, context: Context): - # Setup state state = self.state desc = ( state.description(self, state) @@ -103,169 +95,140 @@ def set_status_text(self, context: Context): ) msg = state_desc(state.name, desc, state.types) - if self.state_data.get("is_numeric_edit", False): - index = self._substate_index - prop = self._stateprop - type = prop.type + if self._numeric.is_active: + prop = self._numeric.prop + index = self._numeric.substate_index array_length = prop.array_length if prop.array_length else 1 - if type == "FLOAT": - input = [0.0] * array_length - for key, val in self._numeric_input.items(): - input[key] = val - input[index] = "*" + str(input[index]) - input = str(input).replace('"', "").replace("'", "") - elif type == "INT": - input = self.numeric_input - - msg += " {}: {}".format(prop.subtype, input) + if prop.type == "FLOAT": + display = [0.0] * array_length + for key in range(array_length): + val = self._numeric.get(key) + display[key] = val if val else 0.0 + display[index] = "*" + str(display[index]) + display_str = str(display).replace('"', "").replace("'", "") + msg += " {}: {}".format(prop.subtype, display_str) + elif prop.type == "INT": + msg += " {}: {}".format(prop.subtype, self._numeric.current) context.workspace.status_text_set(msg) def check_numeric(self): - """Check if the state supports numeric edit""" - + """Return True if the current state supports numeric text entry.""" # TODO: Allow to define custom logic - - state = self.state props = self.get_property() - - # Disable for multi props if not props or len(props) > 1: return False - prop_name = props[0] if not prop_name: return False - prop = self.properties.rna_type.properties.get(prop_name) if not prop: return False + return prop.type in ("INT", "FLOAT") - if prop.type not in ("INT", "FLOAT"): + def init_numeric(self, is_numeric: bool) -> bool: + self._numeric.reset() + if not is_numeric: + self._init_substate() return False - return True - - def init_numeric(self, is_numeric): - self._numeric_input = {} - self._substate_index = 0 - - ok = False - if is_numeric: - ok = self.check_numeric() - # TODO: not when iterating substates - self.state_data["is_numeric_edit"] = is_numeric and ok - - self.init_substate() + ok = self.check_numeric() + self._numeric.is_active = ok + self._init_substate() return ok - def init_substate(self): - - # Reset - self._substate_count = None - self._stateprop = None - + def _init_substate(self): + """Resolve the rna property for the current state and cache it on _numeric.""" props = self.get_property() - if not props: - return - if not props[0]: + if not props or not props[0]: return + prop = self.properties.rna_type.properties.get(props[0]) + self._numeric.init_substate(prop) - prop_name = props[0] - prop = self.properties.rna_type.properties.get(prop_name) - if not prop: - return - - self._substate_count = prop.array_length - self._stateprop = prop + # Public wrappers — kept for API compatibility with operator subclasses def iterate_substate(self): - i = self._substate_index - if i + 1 >= self._substate_count: - i = 0 - else: - i += 1 - self._substate_index = i + self._numeric.iterate() @property - def numeric_input(self): - return self._numeric_input.get(self._substate_index, "") + def numeric_input(self) -> str: + return self._numeric.current @numeric_input.setter - def numeric_input(self, value): - self._numeric_input[self._substate_index] = value + def numeric_input(self, value: str): + self._numeric.current = value - def check_event(self, event): - state = self.state - is_confirm_button = event.type in ("LEFTMOUSE", "RET", "NUMPAD_ENTER") + def evaluate_numeric_event(self, event: Event): + self._numeric.evaluate_event(event) - if is_confirm_button and event.value == "PRESS": - return True - if self.state_index == 0 and not self.wait_for_input: - # Trigger the first state - return not self.state_data.get("is_numeric_edit", False) - if state.no_event: - return True - return False + def validate_numeric_input(self, value: str) -> str: + return self._numeric._validate(value) - def evaluate_numeric_event(self, event: Event): - type = event.type - if type == "BACK_SPACE": - input = self.numeric_input - if len(input): - self.numeric_input = input[:-1] - return + def get_numeric_value(self, context: Context, coords): + """Convert the current numeric text buffer to a typed value (or list).""" + prop_name = self.get_property()[0] + prop = self.properties.rna_type.properties[prop_name] - if type in ("MINUS", "NUMPAD_MINUS"): - input = self.numeric_input - if input.startswith("-"): - input = input[1:] - else: - input = "-" + input - self.numeric_input = input - return + def parse_input(prop, raw): + units = context.scene.unit_settings + unit = prop.unit + value = None + if raw == "-": + pass + elif unit != "NONE": + try: + value = bpy.utils.units.to_value(units.system, unit, raw) + except ValueError: + return prop.default + if prop.type == "INT": + value = int(value) + elif prop.type == "FLOAT": + value = float(raw) + elif prop.type == "INT": + value = int(raw) + return prop.default if value is None else value - if is_unit_input(event): - self.numeric_input += get_unit_value(event) - return + def to_iterable(item): + if hasattr(item, "__iter__") or hasattr(item, "__getitem__"): + return list(item) + return [item] - value = get_value_from_event(event) - self.numeric_input += self.validate_numeric_input(value) + size = max(1, self._numeric.substate_count or 0) - def validate_numeric_input(self, value): - """Check if existing input is valid after appending value""" - num_input = self.numeric_input + # TODO: Don't evaluate interactive value if not needed + interactive_val = self._get_state_values(context, self.state, coords) + if interactive_val is None: + interactive_val = [None] * size + else: + interactive_val = to_iterable(interactive_val) - separators = (".", ",") - if value in separators: - if any([char in num_input for char in separators]): - return "" - if not len(num_input) or not num_input[-1].isdigit(): - return "0." - return value + storage = [None] * size + result = [None] * size + for sub_index in range(size): + raw = self._numeric.get(sub_index) + if raw: + num = parse_input(prop, raw) + result[sub_index] = num + storage[sub_index] = num + elif interactive_val[sub_index] is not None: + result[sub_index] = interactive_val[sub_index] + else: + result[sub_index] = prop.default - def is_in_previous_states(self, entity): - i = self.state_index - 1 - while True: - if i < 0: - break - state = self.get_states()[i] - if state.pointer and entity == getattr(self, state.pointer): - return True - i -= 1 - return False + self.state_data["numeric_input"] = storage + return result[0] if not self._numeric.substate_count else result + + # ------------------------------------------------------------------------- + # Selection prefill + # ------------------------------------------------------------------------- def prefill_state_props(self, context: Context): selected = self.gather_selection(context) - states = self.get_states_definition() - # Iterate states and try to prefill state props while True: index = self.state_index - result = None state = self.state - data = self.get_state_data(index) - coords = None + self.get_state_data(index) if not state.allow_prefill: break @@ -280,67 +243,46 @@ def prefill_state_props(self, context: Context): break return {"RUNNING_MODAL"} - @property - def state_data(self): - return self._state_data.setdefault(self.state_index, {}) - - def get_state_data(self, index): - if not self._state_data.get(index): - self._state_data[index] = {} - return self._state_data[index] - - def get_func(self, state, name): - # fallback to operator method if function isn't specified by state - func = getattr(state, name, None) - - if func: - if isinstance(func, str): - # callback can be specified by function name - return getattr(self, func) - return func - - if hasattr(self, name): - return getattr(self, name) - return None - - def has_func(self, state, name): - return self.get_func(state, name) is not None + # ------------------------------------------------------------------------- + # Operator lifecycle — invoke / modal / execute / _end + # ------------------------------------------------------------------------- - def state_func(self, context, coords): - return NotImplementedError + def check_event(self, event): + is_confirm = event.type in ("LEFTMOUSE", "RET", "NUMPAD_ENTER") + if is_confirm and event.value == "PRESS": + return True + if self.state_index == 0 and not self.wait_for_input: + return not self._numeric.is_active + if self.state.no_event: + return True + return False def invoke(self, context: Context, event: Event): self._state_data.clear() + self._numeric = NumericInput() + self._state_snapshot = self.create_snapshot(context) + if hasattr(self, "init"): if not self.init(context, event): return self._end(context, False) retval = {"RUNNING_MODAL"} - go_modal = True + if is_numeric_input(event): if self.init_numeric(True): - self.evaluate_numeric_event(event) - retval = {"RUNNING_MODAL"} + self._numeric.evaluate_event(event) self.evaluate_state(context, event, False) - # NOTE: This allows to start the operator but waits for action (LMB event). - # Try to fill states based on selection only when this is True since it doesn't - # make senese to respect selection when the user interactivley starts the operator. + # wait_for_input=True: respect selection for prefill, but wait for LMB elif self.wait_for_input: retval = self.prefill_state_props(context) if retval == {"FINISHED"}: go_modal = False - - # NOTE: It might make sense to cancel Operator if no prop could be filled - # Otherwise it might not be obvious that an operator is running - # if self.state_index == 0: - # return self._end(context, False) - if not self.executed and self.check_props(): self.run_op(context) self.executed = True - context.area.tag_redraw() # doesn't seem to work... + context.area.tag_redraw() self.set_status_text(context) @@ -350,118 +292,16 @@ def invoke(self, context: Context, event: Event): return retval succeede = retval == {"FINISHED"} - if succeede: - # NOTE: It seems like there's no undo step pushed if an operator finishes from invoke - # could push an undo_step here however this causes duplicated constraints after redo, - # disable for now - # bpy.ops.ed.undo_push() - pass + # NOTE: Pushing an undo step here causes duplicated constraints after redo. return self._end(context, succeede) - def run_op(self, context: Context): - if not hasattr(self, "main"): - raise NotImplementedError( - "StatefulOperators need to have a main method defined!" - ) - retval = self.main(context) - self.executed = True - return retval - - # Creates non-persistent data - def redo_states(self, context: Context): - for i, state in enumerate(self.get_states()): - if i > self.state_index: - # TODO: don't depend on active state, idealy it's possible to go back - break - if state.pointer: - data = self._state_data.get(i, {}) - is_existing_entity = data["is_existing_entity"] - - props = self.get_property(index=i) - if props and not is_existing_entity: - create = self.get_func(state, "create_element") - - ret_values = create( - context, [getattr(self, p) for p in props], state, data - ) - values = to_list(ret_values) - self.set_state_pointer(values, index=i, implicit=True) - def execute(self, context: Context): + self._numeric = NumericInput() self.redo_states(context) ok = self.main(context) return self._end(context, ok, skip_undo=True) - # maybe allow to be modal again? - - def get_numeric_value(self, context: Context, coords): - state = self.state - prop_name = self.get_property()[0] - prop = self.properties.rna_type.properties[prop_name] - - def parse_input(prop, input): - units = context.scene.unit_settings - unit = prop.unit - type = prop.type - value = None - - if input == "-": - pass - elif unit != "NONE": - try: - value = bpy.utils.units.to_value(units.system, unit, input) - except ValueError: - return prop.default - if type == "INT": - value = int(value) - elif type == "FLOAT": - value = float(input) - elif type == "INT": - value = int(input) - - if value is None: - return prop.default - return value - - size = max(1, self._substate_count) - - def to_iterable(item): - if hasattr(item, "__iter__") or hasattr(item, "__getitem__"): - return list(item) - return [ - item, - ] - - # TODO: Don't evaluate if not needed - interactive_val = self._get_state_values(context, state, coords) - if interactive_val is None: - interactive_val = [None] * size - else: - interactive_val = to_iterable(interactive_val) - - storage = [None] * size - result = [None] * size - - for sub_index in range(size): - num = None - - input = self._numeric_input.get(sub_index) - if input: - num = parse_input(prop, input) - result[sub_index] = num - storage[sub_index] = num - elif interactive_val[sub_index] is not None: - result[sub_index] = interactive_val[sub_index] - else: - result[sub_index] = prop.default - - self.state_data["numeric_input"] = storage - - if not self._substate_count: - return result[0] - return result def _handle_pass_through(self, context: Context, event: Event): - # Only pass through navigation events if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE", "MOUSEMOVE"}: return {"PASS_THROUGH"} return {"RUNNING_MODAL"} @@ -471,24 +311,23 @@ def modal(self, context: Context, event: Event): event_triggered = self.check_event(event) coords = Vector((event.mouse_region_x, event.mouse_region_y)) - is_numeric_edit = self.state_data.get("is_numeric_edit", False) + is_numeric_edit = self._numeric.is_active is_numeric_event = event.value == "PRESS" and is_numeric_input(event) if is_numeric_edit: if is_unit_input(event) and event.value == "PRESS": is_numeric_event = True elif event.type == "TAB" and event.value == "PRESS": - self.iterate_substate() + self._numeric.iterate() self.set_status_text(context) elif is_numeric_event: - # Initialize is_numeric_edit = self.init_numeric(True) if event.type in {"RIGHTMOUSE", "ESC"}: return self._end(context, False) - # HACK: when calling ops.ed.undo() inside an operator a mousemove event - # is getting triggered. manually check if there's a mousemove... + # HACK: calling ops.ed.undo() inside a modal triggers a spurious MOUSEMOVE. + # Check actual pixel movement to filter it out. mousemove_threshold = 0.1 is_mousemove = (coords - self._last_coords).length > mousemove_threshold self._last_coords = coords @@ -497,7 +336,6 @@ def modal(self, context: Context, event: Event): if is_numeric_event: pass elif is_mousemove and is_numeric_edit: - event_triggered = False pass elif not state.interactive: return self._handle_pass_through(context, event) @@ -506,109 +344,179 @@ def modal(self, context: Context, event: Event): # TODO: Disable numeric input when no state.property if is_numeric_event: - self.evaluate_numeric_event(event) + self._numeric.evaluate_event(event) self.set_status_text(context) return self.evaluate_state(context, event, event_triggered) + # ------------------------------------------------------------------------- + # evaluate_state and its sub-steps + # ------------------------------------------------------------------------- + def _get_state_values(self, context: Context, state, coords): - # Get values of state_func, can be none - position_cb = self.get_func(state, "state_func") - if not position_cb: + """Call the state's state_func and return the raw position/value, or None.""" + cb = self.get_func(state, "state_func") + if not cb: return None - pos_val = position_cb(context, coords) - return pos_val + return cb(context, coords) + + def _pick_hovered(self, context: Context, coords, state, is_numeric): + """Try to pick an existing element under the cursor. + + Returns ``(is_picked, pointer_values)`` — pointer_values only valid when + is_picked is True. + """ + if is_numeric or not state.pointer: + return False, None + pick = self.get_func(state, "pick_element") + retval = pick(context, coords) + if retval is not None: + return True, to_list(retval) + return False, None + + def _resolve_values(self, context: Context, coords, state, is_numeric, is_picked): + """Compute property values via state_func or numeric input. + + Returns ``(values, ok)`` — ok indicates the state can advance. + Sets properties on self and marks ``_undo`` when values are produced. + """ + ok = False + values = [] + use_create = state.use_create and self.has_func(state, "create_element") + if not use_create or is_picked: + return values, ok + + if is_numeric: + values = [self.get_numeric_value(context, coords)] + else: + values = to_list(self._get_state_values(context, state, coords)) + + if values: + props = self.get_property() + if props: + for i, v in enumerate(values): + setattr(self, props[i], v) + self._undo = True + ok = not state.pointer + + return values, ok + + def _apply_undo(self, context: Context): + """Restore to snapshot or Blender undo, then replay redo_states.""" + if self._state_snapshot is not None: + self.restore_snapshot(context, self._state_snapshot) + self.on_before_redo_states(context) + self.redo_states(context) + else: + bpy.ops.ed.undo_push(message="Redo: " + self.bl_label) + bpy.ops.ed.undo() + self.on_before_redo_states(context) + self.redo_states(context) + self._undo = False def evaluate_state(self, context: Context, event, triggered): state = self.state data = self.state_data - is_numeric = self.state_data.get("is_numeric_edit", False) + is_numeric = self._numeric.is_active coords = Vector((event.mouse_region_x, event.mouse_region_y)) if self.state_init_coords is None: self.state_init_coords = coords - # Pick hovered element - hovered = None - is_picked = False - if not is_numeric and state.pointer: - pick = self.get_func(state, "pick_element") - pick_retval = pick(context, coords) - - if pick_retval is not None: - is_picked = True - pointer_values = to_list(pick_retval) + is_picked, pointer_values = self._pick_hovered(context, coords, state, is_numeric) + values, ok = self._resolve_values(context, coords, state, is_numeric, is_picked) - # Set state property - ok = False - values = [] - use_create = state.use_create and self.has_func(state, "create_element") - if use_create and not is_picked: - if is_numeric: - # numeric edit is supported for one property only - values = [ - self.get_numeric_value(context, coords), - ] - elif not is_picked: - values = to_list(self._get_state_values(context, state, coords)) - - if values: - props = self.get_property() - if props: - for i, v in enumerate(values): - setattr(self, props[i], v) - self._undo = True - ok = not state.pointer - - # Set state pointer - pointer = None + # Resolve state pointer if state.pointer: if is_picked: - pointer = pointer_values - self.state_data["is_existing_entity"] = True - elif values: - # Let pointer be filled from redo_states - self.state_data["is_existing_entity"] = False + data["is_existing_entity"] = True + self.set_state_pointer(pointer_values, implicit=True) ok = True - - if pointer: - self.set_state_pointer(pointer, implicit=True) + elif values: + # pointer will be filled during redo_states via create_element + data["is_existing_entity"] = False ok = True if self._undo: - bpy.ops.ed.undo_push(message="Redo: " + self.bl_label) - bpy.ops.ed.undo() - global_data.ignore_list.clear() - self.redo_states(context) - self._undo = False + self._apply_undo(context) succeede = False if self.check_props(): succeede = self.run_op(context) self._undo = True - # Iterate state + # State transition if triggered and ok: if not self.next_state(context): if self.check_continuous_draw(): self.do_continuous_draw(context) else: return self._end(context, succeede) - if is_numeric: - # NOTE: Run next state already once even if there's no mousemove yet, - # This is needed in order for the geometry to update + # Run next state once immediately so geometry updates without a mousemove self.evaluate_state(context, event, False) + context.area.tag_redraw() if triggered and not ok: - # Event was triggered on non-valid selection, cancel operator to avoid confusion + # Triggered on non-valid target — cancel to avoid confusion return self._end(context, False) if triggered or is_numeric: return {"RUNNING_MODAL"} return self._handle_pass_through(context, event) + # ------------------------------------------------------------------------- + # Operator execution helpers + # ------------------------------------------------------------------------- + + def run_op(self, context: Context): + if not hasattr(self, "main"): + raise NotImplementedError( + "StatefulOperators need to have a main method defined!" + ) + retval = self.main(context) + self.executed = True + return retval + + def redo_states(self, context: Context): + """Recreate non-persistent elements for states up to the current one.""" + for i, state in enumerate(self.get_states()): + if i > self.state_index: + # TODO: don't depend on active state; ideally going back is possible + break + if state.pointer: + data = self._state_data.get(i, {}) + is_existing_entity = data["is_existing_entity"] + props = self.get_property(index=i) + if props and not is_existing_entity: + create = self.get_func(state, "create_element") + ret_values = create( + context, [getattr(self, p) for p in props], state, data + ) + self.set_state_pointer(to_list(ret_values), index=i, implicit=True) + + def _end(self, context, succeede, skip_undo=False): + context.window.cursor_modal_restore() + if hasattr(self, "fini"): + self.fini(context, succeede) + self.on_before_redo_states(context) + context.workspace.status_text_set(None) + + if not succeede and not skip_undo: + if self._state_snapshot is not None: + self.restore_snapshot(context, self._state_snapshot) + else: + bpy.ops.ed.undo_push(message="Cancelled: " + self.bl_label) + bpy.ops.ed.undo() + + self._state_snapshot = None + return {"FINISHED"} if succeede else {"CANCELLED"} + + # ------------------------------------------------------------------------- + # Continuous draw + # ------------------------------------------------------------------------- + def check_continuous_draw(self): if self.continuous_draw: if not hasattr(self, "continue_draw") or self.continue_draw(): @@ -622,66 +530,45 @@ def _reset_op(self): continue self.set_state_pointer(None, index=i) self._state_data.clear() + self._numeric = NumericInput() + self._state_snapshot = None - def do_continuous_draw(self, context): - # end operator - self._end(context, True) - bpy.ops.ed.undo_push(message=self.bl_label) - - # save last prop - last_pointer = None + def _take_last_state_pointer(self): + """Return (last_index, implicit_values, type_metadata) for the last pointer state.""" for i, s in reversed(list(enumerate(self.get_states()))): if not s.pointer: continue - last_index = i - last_pointer = getattr(self, s.pointer) - break + last_type = self._state_data.get(i, {}).get("type") + values = to_list(self.get_state_pointer(index=i, implicit=True)) + return i, values, last_type + return None, [], None + + def do_continuous_draw(self, context): + """Finish the current segment and immediately start the next one. + + The last pointer of the finished segment (e.g. a line endpoint) + becomes the first pointer of the new segment, creating a chain. + """ + self._end(context, True) + bpy.ops.ed.undo_push(message=self.bl_label) - values = to_list(self.get_state_pointer(index=last_index, implicit=True)) + # Save the endpoint before _reset_op wipes state + last_index, values, last_type = self._take_last_state_pointer() - # reset operator self._reset_op() - data = {} - self._state_data[0] = data + # Re-inject the saved endpoint as the seed for the new segment + data = self.get_state_data(0) data["is_existing_entity"] = True - data["type"] = type(last_pointer) - - # set first pointer + if last_type: + data["type"] = last_type self.set_state_pointer(values, index=0, implicit=True) self.set_state(context, 1) + self._state_snapshot = self.create_snapshot(context) - def _end(self, context, succeede, skip_undo=False): - context.window.cursor_modal_restore() - if hasattr(self, "fini"): - self.fini(context, succeede) - global_data.ignore_list.clear() - - context.workspace.status_text_set(None) - - if not succeede and not skip_undo: - bpy.ops.ed.undo_push(message="Cancelled: " + self.bl_label) - bpy.ops.ed.undo() - - retval = {"FINISHED"} if succeede else {"CANCELLED"} - return retval - - def check_props(self): - for i, state in enumerate(self.get_states()): - - if state.optional: - continue - - props = self.get_property(index=i) - if state.pointer: - if not bool(self.get_state_pointer(index=i)): - return False - - elif props: - for p in props: - if not self.properties.is_property_set(p): - return False - return True + # ------------------------------------------------------------------------- + # Class-level description + # ------------------------------------------------------------------------- @classmethod def description(cls, context, _properties): @@ -693,12 +580,6 @@ def description(cls, context, _properties): hint = get_key_map_desc(context, cls.bl_idname) if hint: descs.append(hint) - if cls.__doc__: descs.append(cls.__doc__) - return stateful_op_desc(" ".join(descs), *states) - - # Dummy methods - def gather_selection(self, context: Context): - raise NotImplementedError diff --git a/stateful_operator/state.py b/stateful_operator/state.py index c2230875..a927230e 100644 --- a/stateful_operator/state.py +++ b/stateful_operator/state.py @@ -1,59 +1,54 @@ -from collections import namedtuple - -OperatorState = namedtuple( - "OperatorState", - ( - "name", # The name to display in the interface - "description", # Text to be displayed in statusbar - # Operator property this state acts upon - # Can also be a list of property names or a callback that returns - # a set of properties dynamically. When not explicitly set to None the - # operators state_property function will be called. - "property", - # Optional: A state can reference an element, pointer attribute set the name of property function - # if set this will be passed to main func, - # state_func should fill main property and create_element should fill this property - # maybe this could just store it in a normal attr, should work as long as the same operator instance is used, test! - "pointer", - "types", # Types the pointer property can accept - "no_event", # Trigger state without an event - "interactive", # Always evaluate state and confirm by user input - "use_create", # Enables or Disables creation of the element - "state_func", # Function to get the state property value from mouse coordinates - "allow_prefill", # Define if state should be filled from selected entities when invoked - "parse_selection", # Prefill Function which chooses entity to use for this stat - "pick_element", - "create_element", - # TODO: Implement! - "use_interactive_placemenet", # Create new state element based on mouse coordinates - "check_pointer", - "optional", # Operator can be run before this state's pointer/property is submitted - ), -) -del namedtuple - - -def state_from_args(name: str, **kwargs): - """ - Use so each state can avoid defining all members of the named tuple. - """ - kw = { - "name": name, - "description": "", - "property": "", - "pointer": None, - "types": (), - "no_event": False, - "interactive": False, - "use_create": True, - "state_func": None, - "allow_prefill": True, - "parse_selection": None, - "pick_element": None, - "create_element": None, - "use_interactive_placemenet": True, - "check_pointer": None, - "optional": False, - } - kw.update(kwargs) - return OperatorState(**kw) +from dataclasses import dataclass, field +from typing import Any, Callable, Optional, Tuple + + +@dataclass(frozen=True) +class OperatorState: + # The name to display in the interface + name: str + + # Text to be displayed in statusbar + description: Any = "" + + # Operator property this state acts upon. + # Can also be a list of property names or a callable that returns + # a set of properties dynamically. When not explicitly set to None the + # operator's state_property method will be called. + property: Any = "" + + # Optional: A state can reference an element via a pointer attribute. + # If set, state_func fills the main property and create_element fills this pointer. + pointer: Optional[str] = None + + # Types the pointer property can accept + types: Tuple = () + + # Trigger state without an event + no_event: bool = False + + # Always evaluate state and confirm by user input + interactive: bool = False + + # Enables or disables creation of the element + use_create: bool = True + + # Callback (or string name) to get the state property value from mouse coordinates + state_func: Any = None + + # Define if state should be filled from selected entities when invoked + allow_prefill: bool = True + + # Prefill callback which chooses an entity to use for this state + parse_selection: Any = None + + pick_element: Any = None + create_element: Any = None + check_pointer: Any = None + + # Operator can be run before this state's pointer/property is submitted + optional: bool = False + + +def state_from_args(name: str, **kwargs) -> OperatorState: + """Create an OperatorState, with all fields optional except name.""" + return OperatorState(name=name, **kwargs) diff --git a/stateful_operator/state_machine.py b/stateful_operator/state_machine.py new file mode 100644 index 00000000..26d3b0e3 --- /dev/null +++ b/stateful_operator/state_machine.py @@ -0,0 +1,156 @@ +from typing import Optional + +from .utilities.generic import to_list + + +class _StateMachineMixin: + """Pure state machine: state definitions, data, and callback/property resolution. + + Has no dependency on Blender's modal operator system, numeric input, or snapshots. + All methods here are usable as soon as ``states`` and ``state_index`` are defined. + + Subclasses must define + ---------------------- + - ``states``: a list of ``OperatorState``, or a callable ``(operator=...)`` that + returns one. + - ``get_state_pointer(index, implicit)`` — provided by the integration layer. + - ``set_state_pointer(values, index, implicit)`` — provided by the integration layer. + """ + + _state_data: dict = {} + + # ------------------------------------------------------------------------- + # State access + # ------------------------------------------------------------------------- + + @classmethod + def get_states_definition(cls): + """Return state list without an operator instance (e.g. at registration time).""" + if callable(cls.states): + return cls.states(operator=None) + return cls.states + + def get_states(self): + """Return the state list, passing self when states is a callable.""" + if callable(self.states): + return self.states(operator=self) + return self.states + + @property + def state(self): + return self.get_states()[self.state_index] + + @state.setter + def state(self, state): + self.state_index = self._index_from_state(state) + + def _index_from_state(self, state): + return [e.name for e in self.get_states()].index(state) + + # ------------------------------------------------------------------------- + # Per-state data store + # ------------------------------------------------------------------------- + + @property + def state_data(self): + return self.get_state_data(self.state_index) + + def get_state_data(self, index): + return self._state_data.setdefault(index, {}) + + # ------------------------------------------------------------------------- + # Property and callback resolution + # ------------------------------------------------------------------------- + + def get_property(self, index: Optional[int] = None): + """Return the operator property name(s) for the given state index.""" + if index is None: + index = self.state_index + state = self.get_states()[index] + + if state.property is None: + return None + + if callable(state.property): + props = state.property(self, index) + elif state.property: + attr = getattr(self, state.property, None) + if callable(attr): + props = attr(index) + else: + props = state.property + elif hasattr(self, "state_property") and callable(self.state_property): + props = self.state_property(index) + else: + return None + + return to_list(props) + + def get_func(self, state, name): + """Resolve a callback from the state definition, falling back to an operator method. + + Priority + -------- + 1. ``state.`` as a callable — used directly. + 2. ``state.`` as a string — looked up as a method on ``self``. + 3. Operator method ``self.`` — used as the default implementation. + """ + func = getattr(state, name, None) + + if func: + if isinstance(func, str): + method = getattr(self, func, None) + if method is None: + raise AttributeError( + f"{type(self).__name__} has no method '{func}' " + f"(referenced by state '{state.name}' field '{name}')" + ) + return method + return func + + if hasattr(self, name): + return getattr(self, name) + return None + + def has_func(self, state, name): + return self.get_func(state, name) is not None + + def state_func(self, context, coords): + raise NotImplementedError + + # ------------------------------------------------------------------------- + # State validation and inspection + # ------------------------------------------------------------------------- + + def check_props(self): + """Return True when every non-optional state has its pointer/property set.""" + for i, state in enumerate(self.get_states()): + if state.optional: + continue + if state.pointer: + if not bool(self.get_state_pointer(index=i)): + return False + else: + props = self.get_property(index=i) + if props: + for p in props: + if not self.properties.is_property_set(p): + return False + return True + + def is_in_previous_states(self, entity): + """Return True if *entity* is already used by an earlier state's pointer.""" + i = self.state_index - 1 + while i >= 0: + state = self.get_states()[i] + if state.pointer and entity == getattr(self, state.pointer): + return True + i -= 1 + return False + + # ------------------------------------------------------------------------- + # Abstract + # ------------------------------------------------------------------------- + + def gather_selection(self, context): + raise NotImplementedError diff --git a/stateful_operator/utilities/generic.py b/stateful_operator/utilities/generic.py index e5238cb0..19ea1989 100644 --- a/stateful_operator/utilities/generic.py +++ b/stateful_operator/utilities/generic.py @@ -54,11 +54,12 @@ def bvhtree_from_object(object: Object) -> BVHTree: if mesh is None or len(mesh.vertices) == 0: return None - bm = bmesh.new() - bm.from_mesh(mesh) - bm.transform(object.matrix_world) - - bvhtree = BVHTree.FromBMesh(bm) - object_eval.to_mesh_clear() - bm.free() + try: + bm = bmesh.new() + bm.from_mesh(mesh) + bm.transform(object.matrix_world) + bvhtree = BVHTree.FromBMesh(bm) + finally: + object_eval.to_mesh_clear() + bm.free() return bvhtree diff --git a/stateful_operator/utilities/keymap.py b/stateful_operator/utilities/keymap.py index 13125805..3e6b9401 100644 --- a/stateful_operator/utilities/keymap.py +++ b/stateful_operator/utilities/keymap.py @@ -123,27 +123,20 @@ def get_unit_value(event: Event): return type.lower() +_EVENT_TO_DIGIT = { + "ZERO": "0", "NUMPAD_0": "0", + "ONE": "1", "NUMPAD_1": "1", + "TWO": "2", "NUMPAD_2": "2", + "THREE": "3", "NUMPAD_3": "3", + "FOUR": "4", "NUMPAD_4": "4", + "FIVE": "5", "NUMPAD_5": "5", + "SIX": "6", "NUMPAD_6": "6", + "SEVEN": "7", "NUMPAD_7": "7", + "EIGHT": "8", "NUMPAD_8": "8", + "NINE": "9", "NUMPAD_9": "9", + "PERIOD": ".", "NUMPAD_PERIOD": ".", +} + + def get_value_from_event(event: Event): - type = event.type - if type in ("ZERO", "NUMPAD_0"): - return "0" - if type in ("ONE", "NUMPAD_1"): - return "1" - if type in ("TWO", "NUMPAD_2"): - return "2" - if type in ("THREE", "NUMPAD_3"): - return "3" - if type in ("FOUR", "NUMPAD_4"): - return "4" - if type in ("FIVE", "NUMPAD_5"): - return "5" - if type in ("SIX", "NUMPAD_6"): - return "6" - if type in ("SEVEN", "NUMPAD_7"): - return "7" - if type in ("EIGHT", "NUMPAD_8"): - return "8" - if type in ("NINE", "NUMPAD_9"): - return "9" - if type in ("PERIOD", "NUMPAD_PERIOD"): - return "." + return _EVENT_TO_DIGIT.get(event.type, "") diff --git a/stateful_operator/utilities/numeric.py b/stateful_operator/utilities/numeric.py new file mode 100644 index 00000000..2f2bf65c --- /dev/null +++ b/stateful_operator/utilities/numeric.py @@ -0,0 +1,72 @@ +from typing import Optional + +from .keymap import is_unit_input, get_unit_value, get_value_from_event + + +class NumericInput: + """Manages the text input buffer for numeric entry in a stateful operator. + + Handles per-substate string buffers (e.g. X, Y, Z components of a vector), + substate cycling via TAB, and validation of entered characters. + """ + + def __init__(self): + self._buffer: dict = {} + self.substate_index: int = 0 + self.substate_count: Optional[int] = None + self.prop = None + self.is_active: bool = False + + def reset(self): + self._buffer = {} + self.substate_index = 0 + self.substate_count = None + self.prop = None + self.is_active = False + + def init_substate(self, prop): + self.substate_count = prop.array_length if prop else None + self.prop = prop + + def iterate(self): + count = self.substate_count or 1 + self.substate_index = (self.substate_index + 1) % count + + @property + def current(self) -> str: + return self._buffer.get(self.substate_index, "") + + @current.setter + def current(self, value: str): + self._buffer[self.substate_index] = value + + def get(self, sub_index: int) -> str: + return self._buffer.get(sub_index, "") + + def evaluate_event(self, event): + event_type = event.type + if event_type == "BACK_SPACE": + if self.current: + self.current = self.current[:-1] + return + + if event_type in ("MINUS", "NUMPAD_MINUS"): + self.current = self.current[1:] if self.current.startswith("-") else "-" + self.current + return + + if is_unit_input(event): + self.current += get_unit_value(event) + return + + value = get_value_from_event(event) + self.current += self._validate(value) + + def _validate(self, value: str) -> str: + """Check if value can be appended to the current input string.""" + separators = (".", ",") + if value in separators: + if any(c in self.current for c in separators): + return "" + if not self.current or not self.current[-1].isdigit(): + return "0." + return value