diff --git a/apps/blender/core/_shared/cp_keys.py b/apps/blender/core/_shared/cp_keys.py index 9772776f..3408597c 100644 --- a/apps/blender/core/_shared/cp_keys.py +++ b/apps/blender/core/_shared/cp_keys.py @@ -91,6 +91,11 @@ PROSCENIO_USER_STEINERS = "proscenio_user_steiners" PROSCENIO_USER_STROKES = "proscenio_user_strokes" PROSCENIO_USER_OUTER_STROKES = "proscenio_user_outer_strokes" +# The user's clicked outer-contour anchor points (pre-subdivision), stored when +# the OUTER stage is authored with the pen contour tool. Lets a re-launch reload +# the authored outline as live, editable pen anchors instead of re-tracing the +# alpha silhouette - the basis of pen re-editing (spec 070). +PROSCENIO_AUTHORED_OUTER_CONTOUR = "proscenio_authored_outer_contour" # Photoshop import tags. Stamped onto imported meshes by the photoshop # importer: the source-layer origin marker (``psd:``), the manifest diff --git a/apps/blender/core/bpy_helpers/automesh/authoring_pipeline.py b/apps/blender/core/bpy_helpers/automesh/authoring_pipeline.py index 8bceaff0..cfc6a805 100644 --- a/apps/blender/core/bpy_helpers/automesh/authoring_pipeline.py +++ b/apps/blender/core/bpy_helpers/automesh/authoring_pipeline.py @@ -13,6 +13,9 @@ import bpy from mathutils import Vector +from ..._shared.cp_keys import ( + PROSCENIO_AUTHORED_OUTER_CONTOUR as _AUTHORED_OUTER_KEY, +) from ..._shared.cp_keys import ( PROSCENIO_USER_OUTER_STROKES as _EDIT_OUTLINE_STROKES_KEY, ) @@ -184,6 +187,24 @@ def write_user_outer_strokes(obj: bpy.types.Object, strokes: list[Stroke]) -> No obj[_EDIT_OUTLINE_STROKES_KEY] = _encode_strokes(strokes) +def read_authored_outer_contour(obj: bpy.types.Object) -> list[Point2D] | None: + """The user's clicked outer-contour anchors (pre-subdivision), or None. + + None means the element was never authored with the pen contour tool (so a + launch should alpha-trace as usual). A present-but-degenerate payload (fewer + than 3 valid points) also reads None - it cannot form a loop to re-edit. + """ + if _AUTHORED_OUTER_KEY not in obj: + return None + points = _parse_stroke_points(read_json_list_cp(obj, _AUTHORED_OUTER_KEY)) + return points if len(points) >= 3 else None + + +def write_authored_outer_contour(obj: bpy.types.Object, points: list[Point2D]) -> None: + """Persist the clicked outer-contour anchors as a JSON list of ``[x, y]``.""" + obj[_AUTHORED_OUTER_KEY] = json.dumps([[float(x), float(y)] for x, y in points]) + + def _parse_stroke_points(raw_pts: list[object]) -> list[tuple[float, float]]: """Coerce a raw points array into validated ``(x, y)`` float pairs. diff --git a/apps/blender/core/skinning/authoring_stages.py b/apps/blender/core/skinning/authoring_stages.py index 9ab270de..3215069c 100644 --- a/apps/blender/core/skinning/authoring_stages.py +++ b/apps/blender/core/skinning/authoring_stages.py @@ -82,6 +82,26 @@ def tool_is_pen(tool: str) -> bool: return tool in _PEN_TOOLS +def resolve_launch_mode(*, has_authored_outer: bool, from_blank: bool) -> str: + """The OUTER-stage launch mode for the authoring modal (spec 070). + + - ``"reedit"``: the element carries stored pen anchors - load them as the + outer and arm the contour pen (re-edit the placed outline). + - ``"blank"``: a from-blank launch on a fresh element - empty outer, contour + pen armed, first click drops the first vert. + - ``"trace"``: the existing behavior - alpha-trace the silhouette. + + Re-edit wins over from-blank: a re-launch on an already-authored element is a + re-edit, never a fresh blank, so a stale from-blank flag cannot wipe an + authored outline. + """ + if has_authored_outer: + return "reedit" + if from_blank: + return "blank" + return "trace" + + class Stroke(TypedDict): """Stage 3 stroke or single-Steiner placement. diff --git a/apps/blender/operators/automesh/__init__.py b/apps/blender/operators/automesh/__init__.py index 6e4cf103..fc359c61 100644 --- a/apps/blender/operators/automesh/__init__.py +++ b/apps/blender/operators/automesh/__init__.py @@ -4,18 +4,21 @@ - automesh - PNG sprite -> annulus mesh - automesh_authoring - PROSCENIO_OT_automesh_authoring modal +- pen_mesh_new - PROSCENIO_OT_pen_mesh_new (from-blank pen element + launch) """ from __future__ import annotations -from . import automesh, automesh_authoring +from . import automesh, automesh_authoring, pen_mesh_new def register() -> None: automesh.register() automesh_authoring.register() + pen_mesh_new.register() def unregister() -> None: + pen_mesh_new.unregister() automesh_authoring.unregister() automesh.unregister() diff --git a/apps/blender/operators/automesh/automesh_authoring.py b/apps/blender/operators/automesh/automesh_authoring.py index 942cbd87..4f92644b 100644 --- a/apps/blender/operators/automesh/automesh_authoring.py +++ b/apps/blender/operators/automesh/automesh_authoring.py @@ -42,8 +42,10 @@ compute_outer, compute_outer_preview, compute_triangulation_preview, + read_authored_outer_contour, read_user_outer_strokes, read_user_strokes, + write_authored_outer_contour, write_user_outer_strokes, write_user_strokes, ) @@ -66,6 +68,7 @@ Stroke, default_tool, next_tool, + resolve_launch_mode, stage_tools, tool_is_pen, ) @@ -202,6 +205,11 @@ class PROSCENIO_OT_automesh_authoring(bpy.types.Operator): # Active tool of the current stage (spec 066: bare Tab cycles it). Mirrored # at class level so the statusbar draw callback can highlight it. _current_active_tool: str = "auto" + # Set by ``proscenio.pen_mesh_new`` right before it launches this modal on a + # freshly created (empty, no alpha to trace) mesh element: the next invoke + # starts from a blank outer with the contour pen armed and the SIMPLE stage + # list, instead of alpha-tracing. Read-and-cleared in invoke (spec 070). + _launch_from_blank: ClassVar[bool] = False @classmethod def poll(cls, context: bpy.types.Context) -> bool: @@ -238,6 +246,18 @@ def invoke(self, context: bpy.types.Context, _event: bpy.types.Event) -> set[str # Stage list depends on interior mode; navigation walks this ordered # list by index, not raw enum arithmetic. self._interior_mode: str = params.interior_mode + # Launch mode (spec 070): a re-edit (the element carries authored anchors) + # loads them instead of alpha-tracing; a from-blank launch starts empty + # with the contour pen on the SIMPLE stage list. Re-edit wins if both hold + # (a re-launch on an authored element is a re-edit, not a fresh blank). + authored_outer = read_authored_outer_contour(obj) + launch_mode = resolve_launch_mode( + has_authored_outer=authored_outer is not None, + from_blank=type(self)._launch_from_blank, + ) + type(self)._launch_from_blank = False + if launch_mode == "blank": + self._interior_mode = "SIMPLE" self._active_stages: list[AuthoringStage] = _stages_for_mode(self._interior_mode) self._output = StageOutput() self._handles = { @@ -310,7 +330,21 @@ def invoke(self, context: bpy.types.Context, _event: bpy.types.Event) -> set[str self._user_outer_strokes: list[Stroke] = [] try: - self._output.outer = compute_outer(obj, image, params) + if authored_outer is not None: + # Re-edit: load the stored anchors as the outer ring and arm the + # contour pen so the artist can redraw / extend it (add points in + # EDIT_OUTLINE), instead of re-tracing the alpha silhouette. + self._output.outer = list(authored_outer) + self._active_tool = "contour" + self._enter_draw(context, "stroke") + elif launch_mode == "blank": + # From blank: nothing to trace; arm the contour pen on an empty + # outer so the first click drops the first vert. + self._output.outer = [] + self._active_tool = "contour" + self._enter_draw(context, "stroke") + else: + self._output.outer = compute_outer(obj, image, params) self._handles = register_overlay(self._stage, self._output) type(self)._current_stage_label = _stage_label(self._stage, self._interior_mode) type(self)._current_stage = self._stage @@ -867,6 +901,13 @@ def _commit_contour( report_warn(self, "manual contour needs at least 3 points", always=True) return self._output.outer = ring + # Persist the user's clicked anchors (pre-subdivision) so a later launch + # reloads this outline for re-editing instead of re-tracing the alpha + # silhouette (spec 070). The ring above is the subdivided render/build + # geometry; the anchors are the editable handles. + obj = context.active_object + if obj is not None: + write_authored_outer_contour(obj, list(pts)) self._handles = refresh_overlay( self._handles, self._stage, self._output, **self._overlay_kwargs() ) diff --git a/apps/blender/operators/automesh/pen_mesh_new.py b/apps/blender/operators/automesh/pen_mesh_new.py new file mode 100644 index 00000000..06e2f051 --- /dev/null +++ b/apps/blender/operators/automesh/pen_mesh_new.py @@ -0,0 +1,138 @@ +"""From-blank pen mesh creation (spec 070). + +Creates a Proscenio mesh element from a chosen image - the same textured quad an +import would build - then launches the interactive authoring modal in from-blank +mode so the artist draws the outline point by point over the image. This is the +Spine / Moho model the STUDY locked: a mesh always overlays a texture region, so +"from blank" means "from an image with no mesh yet", not "from nothing". + +Note: like every other ``bpy.types.Operator`` in the addon this file does NOT use +``from __future__ import annotations`` - Blender's RNA metaclass evaluates the +operator's property annotations eagerly, and PEP 563 would leave them as strings. +""" + +from pathlib import Path +from typing import ClassVar + +import bpy +from bpy.props import StringProperty + +from ...core._shared.props_access import resolve_pixels_per_unit # type: ignore[import-not-found] +from ...core._shared.report import report_error, report_info # type: ignore[import-not-found] + +# The element-build reuses the photoshop importer's quad + material + tag helpers +# so a from-blank pen element is byte-identical to an imported mesh element (quad, +# UV-mapped image material, placement tag, element_type) and re-imports / exports +# the same way. The only difference is it carries no manifest origin. +from ...importers.photoshop.planes import ( # type: ignore[import-not-found] + _attach_material, + _ensure_mesh, + _tag_element_type, +) +from .automesh_authoring import PROSCENIO_OT_automesh_authoring + + +def create_pen_mesh_element( + context: bpy.types.Context, image_path: Path, name: str +) -> bpy.types.Object: + """Build + activate a Proscenio mesh element from ``image_path``. + + Raises ``RuntimeError`` (image load failed) or ``ValueError`` (no pixels) so + the operator surfaces the reason. The element is the imported-mesh shape: a + UV-mapped quad sized from the image and the scene pixels-per-unit, the unlit + image material, the placement tag (so APPLY's UVs land in real texture space), + and ``element_type="mesh"``. + """ + image = bpy.data.images.load(str(image_path), check_existing=True) + width_px, height_px = int(image.size[0]), int(image.size[1]) + if width_px <= 0 or height_px <= 0: + raise ValueError("image has no pixel dimensions") + ppu = resolve_pixels_per_unit(context) + size = (width_px / ppu, height_px / ppu) + obj = _ensure_mesh(name, size) + _attach_material(obj, image_path) + _tag_element_type(obj, "mesh") + for other in context.selected_objects: + other.select_set(False) + obj.select_set(True) + context.view_layer.objects.active = obj + return obj + + +def _launch_authoring_from_blank(context: bpy.types.Context) -> bool: + """Invoke the authoring modal in from-blank mode in a VIEW_3D, if one exists. + + The operator may execute from the file-browser context (after the image + picker), so resolve a real VIEW_3D area + WINDOW region and override into it. + Returns False when no 3D viewport is open (the caller then tells the user to + open Author Mesh manually). + """ + screen = context.screen + if screen is None: + return False + for area in screen.areas: + if area.type != "VIEW_3D": + continue + region = next((r for r in area.regions if r.type == "WINDOW"), None) + if region is None: + continue + PROSCENIO_OT_automesh_authoring._launch_from_blank = True + with context.temp_override(window=context.window, area=area, region=region): + bpy.ops.proscenio.automesh_authoring("INVOKE_DEFAULT") + return True + return False + + +class PROSCENIO_OT_pen_mesh_new(bpy.types.Operator): + """Create a mesh element from an image and open the pen to draw its outline.""" + + bl_idname = "proscenio.pen_mesh_new" + bl_label = "Proscenio: New Pen Mesh" + bl_description = ( + "Pick an image and draw a new mesh element over it point by point with the " + "pen, like Spine / Illustrator. Creates the element, then opens the " + "interactive authoring pen on a blank outline" + ) + bl_options: ClassVar[set[str]] = {"REGISTER", "UNDO"} + + filepath: StringProperty(subtype="FILE_PATH") # type: ignore[valid-type] + name: StringProperty( # type: ignore[valid-type] + name="Name", + description="Name for the new mesh element", + default="PenMesh", + ) + + def invoke(self, context: bpy.types.Context, _event: bpy.types.Event) -> set[str]: + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + def execute(self, context: bpy.types.Context) -> set[str]: + if not self.filepath or not Path(self.filepath).is_file(): + report_error(self, "pick an image file to draw the mesh over") + return {"CANCELLED"} + try: + obj = create_pen_mesh_element(context, Path(self.filepath), self.name) + except (RuntimeError, ValueError) as exc: + report_error(self, f"could not create pen mesh: {exc}") + return {"CANCELLED"} + if _launch_authoring_from_blank(context): + report_info(self, f"created '{obj.name}' - draw its outline with the pen") + else: + report_info( + self, + f"created '{obj.name}' - open Mesh Generation > Author Mesh to draw it", + ) + return {"FINISHED"} + + +_classes: tuple[type, ...] = (PROSCENIO_OT_pen_mesh_new,) + + +def register() -> None: + for cls in _classes: + bpy.utils.register_class(cls) + + +def unregister() -> None: + for cls in reversed(_classes): + bpy.utils.unregister_class(cls) diff --git a/apps/blender/panels/mesh_generation.py b/apps/blender/panels/mesh_generation.py index 189fc90b..dabb3af7 100644 --- a/apps/blender/panels/mesh_generation.py +++ b/apps/blender/panels/mesh_generation.py @@ -45,6 +45,11 @@ def draw_header_preset(self, context: bpy.types.Context) -> None: def draw(self, context: bpy.types.Context) -> None: layout = self.layout + # From-blank pen authoring (spec 070): creates a mesh element from an + # image and opens the pen. Shown always - unlike the edit tools below it + # does not need a mesh element active (it makes one). + layout.operator("proscenio.pen_mesh_new", text="New Pen Mesh", icon="GREASEPENCIL") + layout.separator() obj = context.active_object if obj is None or obj.type != "MESH": layout.label(text="select a mesh to generate or edit", icon="INFO") diff --git a/apps/blender/tests/operators/test_automesh_authoring.py b/apps/blender/tests/operators/test_automesh_authoring.py index b577644c..ffcabdc8 100644 --- a/apps/blender/tests/operators/test_automesh_authoring.py +++ b/apps/blender/tests/operators/test_automesh_authoring.py @@ -446,6 +446,61 @@ def test_user_strokes_round_trip(automesh_fixture): assert len(restored[1]["points"]) == 3 +def test_create_pen_mesh_element_builds_a_mesh_element(automesh_fixture, tmp_path): + """The from-blank helper builds an imported-shape mesh element from an image: + a MESH with an image material, the placement tag, element_type 'mesh', active.""" + from pathlib import Path + + from proscenio.core._shared.cp_keys import ( # type: ignore[import-not-found] + PROSCENIO_IMPORT_PLACEMENT, + ) + from proscenio.core._shared.material_images import ( # type: ignore[import-not-found] + first_material_image, + ) + from proscenio.core._shared.props_access import ( # type: ignore[import-not-found] + element_type_of, + ) + from proscenio.operators.automesh.pen_mesh_new import ( # type: ignore[import-not-found] + create_pen_mesh_element, + ) + + img_path = Path(tmp_path) / "pen_src.png" + img = bpy.data.images.new("pen_src", width=64, height=32) + img.filepath_raw = str(img_path) + img.file_format = "PNG" + img.save() + + obj = create_pen_mesh_element(bpy.context, img_path, "PenTest") + assert obj.type == "MESH" + assert element_type_of(obj) == "mesh" + assert PROSCENIO_IMPORT_PLACEMENT in obj + assert first_material_image(obj) is not None + assert bpy.context.view_layer.objects.active is obj + + +def test_authored_outer_contour_round_trip(automesh_fixture): + """The clicked outer anchors round-trip; absent reads None; degenerate reads None.""" + obj = _activate("hand") + from proscenio.core.bpy_helpers.automesh.authoring_pipeline import ( # type: ignore[import-not-found] + read_authored_outer_contour, + write_authored_outer_contour, + ) + + if "proscenio_authored_outer_contour" in obj: + del obj["proscenio_authored_outer_contour"] + # Never authored -> None (so a launch alpha-traces as usual, not re-edits). + assert read_authored_outer_contour(obj) is None + + anchors = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + write_authored_outer_contour(obj, anchors) + restored = read_authored_outer_contour(obj) + assert restored == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + + # A degenerate payload (cannot form a loop) reads None, not a broken ring. + write_authored_outer_contour(obj, [(0.0, 0.0), (1.0, 0.0)]) + assert read_authored_outer_contour(obj) is None + + def test_user_strokes_legacy_fallback(automesh_fixture): """Legacy proscenio_user_steiners (flat list) reads as kind=point strokes.""" obj = _activate("hand") diff --git a/tests/skinning/test_authoring_stages.py b/tests/skinning/test_authoring_stages.py index 9c9b0b00..62995337 100644 --- a/tests/skinning/test_authoring_stages.py +++ b/tests/skinning/test_authoring_stages.py @@ -8,6 +8,7 @@ StageParams, default_tool, next_tool, + resolve_launch_mode, stage_tools, tool_is_pen, ) @@ -120,3 +121,14 @@ def test_tool_is_pen_classification(): assert tool_is_pen(pen) is True for non_pen in ("auto", "point", ""): assert tool_is_pen(non_pen) is False + + +def test_resolve_launch_mode(): + # No authored anchors, no from-blank flag -> the existing alpha trace. + assert resolve_launch_mode(has_authored_outer=False, from_blank=False) == "trace" + # A fresh from-blank launch -> empty contour. + assert resolve_launch_mode(has_authored_outer=False, from_blank=True) == "blank" + # Stored anchors -> re-edit, regardless of the from-blank flag (re-edit wins, + # so a stale flag can never wipe an authored outline). + assert resolve_launch_mode(has_authored_outer=True, from_blank=False) == "reedit" + assert resolve_launch_mode(has_authored_outer=True, from_blank=True) == "reedit"