Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/blender/core/_shared/cp_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<layer>``), the manifest
Expand Down
21 changes: 21 additions & 0 deletions apps/blender/core/bpy_helpers/automesh/authoring_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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.

Expand Down
20 changes: 20 additions & 0 deletions apps/blender/core/skinning/authoring_stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion apps/blender/operators/automesh/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
43 changes: 42 additions & 1 deletion apps/blender/operators/automesh/automesh_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -66,6 +68,7 @@
Stroke,
default_tool,
next_tool,
resolve_launch_mode,
stage_tools,
tool_is_pen,
)
Expand Down Expand Up @@ -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
Comment on lines +208 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

_launch_from_blank can leak into the next unrelated launch.

apps/blender/operators/automesh/pen_mesh_new.py:62-83 arms this class flag before invoking the modal, but it is only cleared here after the early validation path. If poll fails or invoke() exits on the mesh/image guards, the flag stays True, and the next normal authoring launch can resolve to "blank" instead of "trace". Clear it before the guards and reset it in the caller with try/finally, or stop using cross-invocation class state here.

Also applies to: 249-258

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blender/operators/automesh/automesh_authoring.py` around lines 208 -
212, The class flag _launch_from_blank in AutomeshAuthoring can persist into
later launches because it is only cleared on the successful invoke path. Update
AutomeshAuthoring.invoke to reset this flag before any poll/mesh/image
validation guards, and ensure the caller in pen_mesh_new uses try/finally to
clear it even when invoke exits early. If possible, avoid relying on
cross-invocation class state for launch mode selection and keep the blank/trace
decision local to the current invocation.


@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Comment on lines +333 to +345

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Re-edit still starts from an empty contour.

_enter_draw() clears _pen_points, and this branch only copies authored_outer into _output.outer. On reopen the old ring is visible, but the contour tool itself has no seeded anchors, so _pen_finish() can only commit a brand-new line. Seed the pen state from authored_outer here so reopening actually edits the saved outline.

Suggested fix
             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")
+                self._pen_points[:] = list(authored_outer)
+                self._pen_active = True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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")
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")
self._pen_points[:] = list(authored_outer)
self._pen_active = True
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")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blender/operators/automesh/automesh_authoring.py` around lines 333 -
345, The re-edit branch in automesh_authoring.py restores authored_outer into
_output.outer, but _enter_draw() clears _pen_points so the contour tool starts
with no editable anchors. Update the re-edit flow in the _active_tool =
"contour" path to seed the pen state from authored_outer before calling
_enter_draw(), so _pen_finish() continues editing the saved outline instead of
creating a fresh line. Keep the blank-launch branch unchanged and make the fix
in the same logic that handles authored_outer / launch_mode.

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
Expand Down Expand Up @@ -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))
Comment on lines +904 to +910

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The authored-outline CP is written before the final outer is chosen.

_commit_contour() persists the re-edit key immediately, but the OUTER stage can still switch back to "auto" on Lines 531-546 and apply the traced silhouette instead. Nothing clears the authored-contour CP on that path, so the next reopen resolves to "reedit" and reloads a contour the user already backed out of. Persist this only when APPLY commits a contour-driven outer, or clear it whenever OUTER returns to auto.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blender/operators/automesh/automesh_authoring.py` around lines 904 -
910, The authored contour is being persisted too early in _commit_contour(),
before the OUTER stage is finalized, so a later switch back to auto can still
leave a stale re-edit key behind. Update the commit flow around
_commit_contour() and the OUTER handling so write_authored_outer_contour() is
called only when APPLY actually commits the contour-driven outer, or explicitly
clear the authored-contour state whenever the OUTER path falls back to auto.

self._handles = refresh_overlay(
self._handles, self._stage, self._output, **self._overlay_kwargs()
)
Expand Down
138 changes: 138 additions & 0 deletions apps/blender/operators/automesh/pen_mesh_new.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +70 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Search all Blender windows for the VIEW_3D override.

Line 70 only walks context.screen, but after fileselect_add() this operator can be executing on the file-browser screen. In that case the loop never sees the main window's VIEW_3D, so the new-pen flow falls back to the manual “Author Mesh” path even though a viewport is already open. Iterate context.window_manager.windows and override into the first VIEW_3D window instead.

Suggested fix
 def _launch_authoring_from_blank(context: bpy.types.Context) -> bool:
@@
-    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
+    for window in context.window_manager.windows:
+        screen = window.screen
+        if screen is None:
+            continue
+        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=window, area=area, region=region):
+                bpy.ops.proscenio.automesh_authoring("INVOKE_DEFAULT")
+            return True
     return False
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
for window in context.window_manager.windows:
screen = window.screen
if screen is None:
continue
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=window, area=area, region=region):
bpy.ops.proscenio.automesh_authoring("INVOKE_DEFAULT")
return True
return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blender/operators/automesh/pen_mesh_new.py` around lines 70 - 83, The
pen-mesh launch helper only searches the current screen, so it can miss an
already-open 3D viewport when running from the file browser. Update the override
lookup in the blank-pen flow to iterate through context.window_manager.windows,
find the first window containing a VIEW_3D area and WINDOW region, and use that
with context.temp_override before calling PROSCENIO_OT_automesh_authoring. Keep
the existing fallback return False behavior if no suitable viewport is found.



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)
5 changes: 5 additions & 0 deletions apps/blender/panels/mesh_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
55 changes: 55 additions & 0 deletions apps/blender/tests/operators/test_automesh_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +460 to +476

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the persisted element-type tag here.

element_type_of() defaults to "mesh" when the property is missing, so Line 475 still passes even if create_pen_mesh_element() stops calling _tag_element_type(). Check the stored tag directly so this test actually protects the new contract.

Suggested fix
-    from proscenio.core._shared.props_access import (  # type: ignore[import-not-found]
-        element_type_of,
-    )
@@
-    assert element_type_of(obj) == "mesh"
+    props = getattr(obj, "proscenio", None)
+    if props is not None:
+        assert props.element_type == "mesh"
+    assert obj.get("proscenio_type") == "mesh"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
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"
props = getattr(obj, "proscenio", None)
if props is not None:
assert props.element_type == "mesh"
assert obj.get("proscenio_type") == "mesh"
assert PROSCENIO_IMPORT_PLACEMENT in obj
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blender/tests/operators/test_automesh_authoring.py` around lines 460 -
476, The test in create_pen_mesh_element is only verifying the default from
element_type_of(), so it will still pass even if _tag_element_type() is no
longer called. Update the assertion around create_pen_mesh_element and
PROSCENIO_IMPORT_PLACEMENT to check the persisted element-type tag directly on
the created object, so the test validates that the tag is actually stored rather
than inferred.

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")
Expand Down
Loading
Loading