Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
"""3D-view snapshot / restore for the Quick Armature modal session.

Captures the pre-invoke view, optionally snaps to Front Orthographic, and on
exit restores the pre-snap view unless the user orbited mid-modal. Held by the
operator as a single collaborator so the view lifecycle is one object rather
than a dozen parallel ClassVars.
"""3D-view snapshot / restore + a front-ortho modal mixin.

``ViewSnapshot`` captures the pre-invoke view, optionally snaps to Front
Orthographic, and on exit restores the pre-snap view unless the user orbited
mid-modal. ``FrontOrthoModalMixin`` wraps that lifecycle for any interactive
authoring operator: the tools author on the flat Y=0 picture plane, so entering
from a front view removes depth ambiguity (spec 078). Quick Armature was the
first user; the mixin lets Automesh / Manual Mesh / Edit Weights share it.

Note: no ``from __future__ import annotations`` here. Blender 5.1's RNA metaclass
evaluates the ``lock_to_front_ortho`` BoolProperty annotation eagerly; PEP 563
would leave it a string and the property would silently never register (the same
constraint the operator modules document).
"""

from __future__ import annotations

from collections.abc import Callable

import bpy
from bpy.props import BoolProperty
from mathutils import Quaternion, Vector

from .._shared.viewport_math import ( # type: ignore[import-not-found]
from .viewport_math import ( # type: ignore[import-not-found]
rv3d_is_front_ortho,
view_pose_equal,
)

_Report = Callable[[str], None]


def _log_view(label: str, rv3d: bpy.types.RegionView3D) -> None:
def _log_view(tag: str, label: str, rv3d: bpy.types.RegionView3D) -> None:
"""Print a one-line view state snapshot to the console.

Logs persistent (location, rotation, distance) + the active
Expand All @@ -31,7 +37,7 @@ def _log_view(label: str, rv3d: bpy.types.RegionView3D) -> None:
loc = rv3d.view_location
rot = rv3d.view_rotation
print(
f"[Proscenio.QuickArmature] {label}: "
f"[Proscenio.{tag}] {label}: "
f"perspective={rv3d.view_perspective} "
f"location=({loc.x:.3f}, {loc.y:.3f}, {loc.z:.3f}) "
f"rotation=(w={rot.w:.3f}, x={rot.x:.3f}, y={rot.y:.3f}, z={rot.z:.3f}) "
Expand All @@ -40,9 +46,10 @@ def _log_view(label: str, rv3d: bpy.types.RegionView3D) -> None:


class ViewSnapshot:
"""Records + restores the region's view around a Quick Armature session."""
"""Records + restores the region's view around an interactive session."""

def __init__(self) -> None:
def __init__(self, tag: str = "Proscenio") -> None:
self.tag = tag
self.region_data: bpy.types.RegionView3D | None = None
self.perspective: str | None = None
self.location: Vector | None = None
Expand All @@ -62,7 +69,7 @@ def capture(self, context: bpy.types.Context) -> None:
self.location = rv3d.view_location.copy()
self.rotation = rv3d.view_rotation.copy()
self.distance = float(rv3d.view_distance)
_log_view("invoke (pre-snap)", rv3d)
_log_view(self.tag, "invoke (pre-snap)", rv3d)

def snap_to_front_ortho(self, context: bpy.types.Context, report: _Report) -> None:
rv3d = getattr(context, "region_data", None)
Expand All @@ -79,13 +86,13 @@ def snap_to_front_ortho(self, context: bpy.types.Context, report: _Report) -> No
self.post_snap_rotation = rv3d.view_rotation.copy()
self.post_snap_distance = float(rv3d.view_distance)
report("snapped to Front Orthographic")
_log_view("post-snap", rv3d)
_log_view(self.tag, "post-snap", rv3d)

def restore(self, report: _Report) -> None:
rv3d = self.region_data
if rv3d is None:
return
_log_view("exit (before restore decision)", rv3d)
_log_view(self.tag, "exit (before restore decision)", rv3d)
if not self.did_auto_snap:
# User did not request snap, nothing to restore.
self.clear()
Expand Down Expand Up @@ -114,8 +121,51 @@ def restore(self, report: _Report) -> None:
if self.perspective is not None:
rv3d.view_perspective = self.perspective
report("view restored to pre-snap")
_log_view("exit (after restore)", rv3d)
_log_view(self.tag, "exit (after restore)", rv3d)
self.clear()

def clear(self) -> None:
self.__init__()
tag = self.tag
self.__init__(tag)


class FrontOrthoModalMixin:
"""Snap the view to Front Orthographic for a modal authoring session.

Mix into a modal operator that authors on the Y=0 picture plane. Call
``enter_front_ortho`` in ``invoke`` (after the precondition gate) and
``exit_front_ortho`` in ``_finish`` / ``cancel``. The ``lock_to_front_ortho``
property (default on) lets the artist author from the current view instead.
"""

# Same msgid as Quick Armature's own toggle (already translated), so sharing
# the string keeps this mixin out of the i18n catalog as a new entry.
lock_to_front_ortho: BoolProperty( # type: ignore[valid-type]
name="Lock to Front Orthographic",
description=(
"Switch to Front Orthographic on invoke and restore the previous "
"view on exit. Uncheck to author from any view (the picture plane "
"is still locked to Y=0)."
),
default=True,
)

_front_ortho_view: ViewSnapshot | None = None

def enter_front_ortho(
self,
context: bpy.types.Context,
report: _Report,
*,
tag: str = "Proscenio",
) -> None:
self._front_ortho_view = ViewSnapshot(tag=tag)
self._front_ortho_view.capture(context)
if self.lock_to_front_ortho:
self._front_ortho_view.snap_to_front_ortho(context, report)

def exit_front_ortho(self, report: _Report) -> None:
view = getattr(self, "_front_ortho_view", None)
if view is not None:
view.restore(report)
self._front_ortho_view = None
18 changes: 9 additions & 9 deletions apps/blender/core/bpy_helpers/automesh/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,14 +568,12 @@ def _compute_steiner_points(
Three-step funnel:

1. ``interior_points_for_annulus`` produces a candidate grid
(uniform OR bone-clustered when picker armature is set).
``inner_world`` is passed so the helper skips points inside
the inner ring when the user opted into the annulus topology
(margin_pixels > 0); without it, points inside the ring
would survive into CDT only to become loose verts after the
inner ring's hole exclusion. When ``inner_world`` is empty
(margin_pixels = 0, default), the helper treats the whole
outer interior as fair game.
(uniform OR bone-clustered when picker armature is set)
filling the whole outer interior. The inner ring is a
constraint loop, not a hole (spec 078), so interior points
inside it belong in the mesh - the grid is no longer clipped
to the annulus band, which is what made Dense collapse onto
Simple.
2. Points falling inside any detected alpha hole are dropped -
CDT excludes the hole region, so a Steiner there would
become a loose vertex with no incident face.
Expand All @@ -584,7 +582,9 @@ def _compute_steiner_points(
"""
interior_points = interior_points_for_annulus(
outer_world,
inner_world,
# Fill the whole silhouette, not just the annulus band: the inner ring
# is no longer a hole (spec 078), so points inside it belong in the mesh.
[],
interior_spacing,
bone_segments=bone_segments,
bone_density_radius=bone_density_radius,
Expand Down
28 changes: 19 additions & 9 deletions apps/blender/core/bpy_helpers/automesh/cdt.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,16 @@ def build_mesh_via_delaunay(

- Treat outer + inner cyclic edges as hard constraints (must
appear in the output).
- Auto-detect the inner ring AND every hole loop as HOLEs via
``output_type=2`` (CDT_INSIDE_WITH_HOLES) when present, so the
interior of the inner ring + every alpha hole is correctly
excluded from triangulation.
- Always fill every constrained region (``output_type=1``,
CDT_INSIDE) and NEVER auto-detect holes. ``output_type=2``'s
nested-loop detection is unreliable against the bridge's
orientation flow and cannot reliably tell a genuine alpha hole
from the eroded inner ring, so with a real hole present it could
re-carve the inner ring's interior into a hole and collapse Dense
onto Simple again (spec 078). Genuine alpha holes are carved
downstream by the deterministic ``delete_faces_inside_holes``
centroid prune (bridge), the reliable mechanism; the inner ring is
a constraint loop only and its interior stays FILLED.
- Include Steiner interior points as additional verts from the
start so they participate in the Delaunay rather than being
fan-split into an existing fan triangulation afterwards.
Expand All @@ -170,15 +176,19 @@ def build_mesh_via_delaunay(
"""
if len(outer_world) < 3:
return 0
# Filter degenerate hole loops up front: a <3-vertex hole adds no valid
# constraint, so it must not flip the output type to with-holes (which
# would auto-detect holes the constraint edges never carved). With the
# filter here, the _build_cdt_inputs guard is redundant and dropped.
# Filter degenerate hole loops up front: a <3-vertex hole is not a valid
# constraint edge loop. With the filter here, the _build_cdt_inputs guard
# is redundant and dropped.
holes = [hole for hole in holes_world if len(hole) >= 3] if holes_world else []
all_coords, edges_constraint = _build_cdt_inputs(
outer_world, inner_world, interior_points, holes, extra_edges=extra_edges
)
output_type = 2 if (len(inner_world) >= 3 or holes) else 1
# Never auto-detect holes. CDT auto-hole-detection is unreliable against the
# bridge's orientation flow (module docstring), so with a real hole present
# CDT_INSIDE_WITH_HOLES could also mistake the eroded inner ring for a hole
# and re-empty the centre (spec 078). Fill every region and let
# delete_faces_inside_holes (bridge) carve the real holes deterministically.
output_type = 1
result = mathutils.geometry.delaunay_2d_cdt(
all_coords,
edges_constraint,
Expand Down
6 changes: 3 additions & 3 deletions apps/blender/core/i18n_locales/pt_br.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
),
"Ossos além desta distância contribuem com zero (somente PROXIMITY). -1 = adaptativo (1.5x o bbox da armadura)",
),
(("*", "Boundary margin (annulus)"), "Margem de borda (annulus)"),
(("*", "Boundary margin (edge loop)"), "Margem de borda (loop de arestas)"),
(("*", "Brush curve preset:"), "Predefinição de curva do pincel:"),
(("*", "Build the mesh by clicking vertices"), "Construir a malha clicando nos vértices"),
(("*", "Bundle textures"), "Agrupar texturas"),
Expand Down Expand Up @@ -783,9 +783,9 @@
(
(
"*",
"Source-pixel margin that builds an ANNULUS topology (dilated outer ring + eroded inner ring + Constrained Delaunay between them). Zero (default) skips the annulus and produces a single-contour flat triangulation - the common case for 2D skinning (matches Spine / DragonBones). Set > 0 only when you want extra edge-loop density at the silhouette for fine border deformation control (cape, hair, ribbon).",
"Source-pixel margin that adds an inner edge-density loop (the silhouette eroded inward) as an extra constraint ring near the boundary. The mesh interior stays FILLED - the loop only adds silhouette edge density, it does not carve a hole. Zero (default) skips it and produces a single-contour triangulation, the common case for 2D skinning (matches Spine / DragonBones). Set > 0 for fine border deformation control (cape, hair, ribbon).",
),
"Margem de pixel de origem que constrói uma topologia ANNULUS (anel externo dilatado + anel interno erodido + Delaunay Restrito entre eles). Zero (padrão) pula o annulus e produz uma triangulação plana de contorno único - o caso comum para skinning 2D (segue Spine / DragonBones). Defina > 0 apenas quando quiser densidade extra de loops de arestas na silhueta para controle fino de deformação de borda (capa, cabelo, fita).",
"Margem em pixels de origem que adiciona um loop interno de densidade de arestas (a silhueta erodida para dentro) como um anel de restrição extra perto da borda. O interior da malha permanece PREENCHIDO - o loop só adiciona densidade de arestas na silhueta, não recorta um buraco. Zero (padrão) pula isso e produz uma triangulação de contorno único, o caso comum para skinning 2D (segue Spine / DragonBones). Defina > 0 para controle fino de deformação de borda (capa, cabelo, fita).",
),
(("*", "Spacing"), "Espaçamento"),
(
Expand Down
10 changes: 5 additions & 5 deletions apps/blender/operators/armature/quick_armature.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
from ...core.bpy_helpers._shared.redraw import ( # type: ignore[import-not-found]
tag_redraw_view3d_statusbar,
)
from ...core.bpy_helpers._shared.view_session import ( # type: ignore[import-not-found]
ViewSnapshot,
)
from ...core.bpy_helpers._shared.viewport_math import ( # type: ignore[import-not-found]
find_window_region,
mouse_event_to_plane_point,
Expand All @@ -64,9 +67,6 @@
BoneSession,
author_edit_bone,
)
from ...core.bpy_helpers.armature.view_session import ( # type: ignore[import-not-found]
ViewSnapshot,
)
from ._overlay import draw_cursor_warning_2d, draw_preview_3d
from ._status_bar import emit_chord_layout

Expand Down Expand Up @@ -113,7 +113,7 @@ class PROSCENIO_OT_quick_armature(bpy.types.Operator):
_created_armature_this_session: ClassVar[bool] = False
# The view snap/restore lifecycle lives in one collaborator (replaced fresh
# each invoke) instead of a dozen parallel ClassVars.
_view: ClassVar[ViewSnapshot] = ViewSnapshot()
_view: ClassVar[ViewSnapshot] = ViewSnapshot(tag="QuickArmature")
_restore_selected_names: ClassVar[tuple[str, ...]] = ()
_restore_active_name: ClassVar[str] = ""
_invoke_area: ClassVar[bpy.types.Area | None] = None
Expand Down Expand Up @@ -197,7 +197,7 @@ def invoke(self, context: bpy.types.Context, _event: bpy.types.Event) -> set[str
cls._preview_handle_3d = None
cls._cursor_warning_handle_2d = None
cls._created_armature_this_session = False
cls._view = ViewSnapshot()
cls._view = ViewSnapshot(tag="QuickArmature")
cls._restore_selected_names = ()
cls._restore_active_name = ""
cls._ctrl_held = False
Expand Down
10 changes: 9 additions & 1 deletion apps/blender/operators/automesh/automesh_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
from ...core.bpy_helpers._shared.redraw import ( # type: ignore[import-not-found]
tag_redraw_view3d_statusbar,
)
from ...core.bpy_helpers._shared.view_session import ( # type: ignore[import-not-found]
FrontOrthoModalMixin,
)
from ...core.bpy_helpers._shared.viewport_math import ( # type: ignore[import-not-found]
event_in_canvas,
find_window_region,
Expand Down Expand Up @@ -217,7 +220,7 @@ class AuthoringModalState:
tool: str


class PROSCENIO_OT_automesh_authoring(bpy.types.Operator):
class PROSCENIO_OT_automesh_authoring(FrontOrthoModalMixin, bpy.types.Operator):
"""Multi-stage modal preview of the automesh pipeline."""

bl_idname = "proscenio.automesh_authoring"
Expand Down Expand Up @@ -287,6 +290,9 @@ def invoke(self, context: bpy.types.Context, _event: bpy.types.Event) -> set[str
obj = validate_authoring_invoke(self, context, _MODAL_NAME)
if obj is None:
return {"CANCELLED"}
# Author on the flat Y=0 picture plane: snap to Front Ortho by default
# (spec 078). Restored in _finish.
self.enter_front_ortho(context, lambda m: report_info(self, m), tag="Automesh")
# Fresh session: drop any cached alpha grid so an edited texture is
# re-read. Within the session the cache then serves every threshold /
# margin drag without re-walking the whole image.
Expand Down Expand Up @@ -1474,6 +1480,8 @@ def _finish(self, context: bpy.types.Context, *, cancel: bool) -> set[str]:
context.window_manager.event_timer_remove(self._timer)
type(self)._timer = None
self._remove_statusbar()
with contextlib.suppress(Exception):
self.exit_front_ortho(lambda m: report_info(self, m))
if self._session is not None:
restore_session(context, self._session)
finally:
Expand Down
10 changes: 9 additions & 1 deletion apps/blender/operators/automesh/draw_mesh_vertices.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
from ...core.bpy_helpers._shared.redraw import ( # type: ignore[import-not-found]
tag_redraw_view3d_statusbar,
)
from ...core.bpy_helpers._shared.view_session import ( # type: ignore[import-not-found]
FrontOrthoModalMixin,
)
from ...core.bpy_helpers._shared.viewport_math import ( # type: ignore[import-not-found]
event_in_canvas,
find_window_region,
Expand Down Expand Up @@ -117,7 +120,7 @@ def _world_xz_to_local(
return out


class PROSCENIO_OT_draw_mesh_vertices(bpy.types.Operator):
class PROSCENIO_OT_draw_mesh_vertices(FrontOrthoModalMixin, bpy.types.Operator):
"""Manual Draw: build the mesh silhouette by clicking vertices.

LMB place a vertex (click the first vertex to close), RMB drag a vertex,
Expand Down Expand Up @@ -187,6 +190,9 @@ def invoke(self, context: bpy.types.Context, _event: bpy.types.Event) -> set[str
obj = validate_authoring_invoke(self, context, _MODAL_NAME)
if obj is None:
return {"CANCELLED"}
# Author on the flat Y=0 picture plane: snap to Front Ortho by default
# (spec 078). Restored in _finish.
self.enter_front_ortho(context, lambda m: report_info(self, m), tag="ManualMesh")

self._session = capture_session(context, obj)
# Viewport canvas region (not the N-panel button's): lets the modal pass
Expand Down Expand Up @@ -723,6 +729,8 @@ def _finish(self, context: bpy.types.Context, *, cancel: bool) -> set[str]:
with contextlib.suppress(RuntimeError):
unregister_overlay(self._handles)
self._remove_statusbar()
with contextlib.suppress(Exception):
self.exit_front_ortho(lambda m: report_info(self, m))
if self._session is not None and cancel:
restore_session(context, self._session)
finally:
Expand Down
11 changes: 10 additions & 1 deletion apps/blender/operators/skinning/edit_weights.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
report_info,
)
from ...core.bpy_helpers._shared.redraw import tag_redraw_areas # type: ignore[import-not-found]
from ...core.bpy_helpers._shared.view_session import ( # type: ignore[import-not-found]
FrontOrthoModalMixin,
)
from ...core.bpy_helpers.i18n import iface
from ...core.bpy_helpers.skinning import ( # type: ignore[import-not-found]
StrokeDiffTracker,
Expand All @@ -53,7 +56,7 @@
_MODE_WATCH_INTERVAL = 0.2


class PROSCENIO_OT_edit_weights_modal(bpy.types.Operator):
class PROSCENIO_OT_edit_weights_modal(FrontOrthoModalMixin, bpy.types.Operator):
"""Enter a 2D-safe weight paint context with provenance overlay."""

bl_idname = "proscenio.edit_weights"
Expand Down Expand Up @@ -97,6 +100,10 @@ def invoke(self, context: bpy.types.Context, _event: bpy.types.Event) -> set[str
if not require_object_visible(self, obj, action="edit weights"):
return {"CANCELLED"}

# The mesh is authored on the Y=0 picture plane; paint from a flat front
# view by default (spec 078). Restored in _finish.
self.enter_front_ortho(context, lambda m: report_info(self, m), tag="EditWeights")

skinning = getattr(scene_props, "skinning", None)
prior_overlay = (
bool(getattr(skinning.authoring, "show_provenance_overlay", False))
Expand Down Expand Up @@ -190,6 +197,8 @@ def _finish(self, context: bpy.types.Context, *, cancel: bool) -> set[str]:
unregister_handler(self._overlay_handle)
self._overlay_handle = None
self._remove_statusbar()
with contextlib.suppress(Exception):
self.exit_front_ortho(lambda m: report_info(self, m))
# A normal finish captures the session's end weights as a rolling
# auto-snapshot. Suppressed so a snapshot failure never aborts the
# mode/selection restore below (cleanup must run in every exit path).
Expand Down
Loading