# SPDX-FileCopyrightText: 2021-2026 Julien Rippinger, Ian Bertin <alicelab.be>
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Defines camera operators and scene handlers for the Parallel Cameras add-on.
"""
import bpy
from bpy.app.handlers import persistent
from math import radians, pi, sin
from .utils.geometry_math import (
calculate_altitude_with_z_value,
)
from .utils.ui_shared import draw_create_cam_content
from .utils.helpers import camera_attributes_updated, camera_type_updated
from .utils.data import CAMERA_SETTINGS
# -------------------------------------------------------------------
# GLOBAL VARIABLES
# -------------------------------------------------------------------
_last_cam = None
_alpha = None
_beta = None
_z_value = None
_projection_type = None
_axonometric_type = None
_oblique_type = None
# -------------------------------------------------------------------
# OPERATOR
# -------------------------------------------------------------------
[docs]
class Pohlke_OT_CreateCam(bpy.types.Operator):
"""Operator responsible for creating a parallel projection camera.
Attributes
----------
projection_type : enum
Main projection category:
* 'AXONOMETRIC' : Axonometric projections.
* 'OBLIQUE' : Oblique projections.
axonometric_type : enum
Sub-preset for axonometric cameras.
Available values are dynamically generated from the projection
definitions declared in `presets.toml` and loaded via `data.py`.
They are no longer hardcoded.
Each preset follows the naming pattern:
'AXONOMETRIC_#<n>'
where <n> is the index of the preset as defined in `presets.toml`.
A 'CUSTOM' entry is automatically added when the current
projection parameters do not match any predefined preset.
oblique_type : enum
Sub-preset for oblique cameras.
Available values are dynamically generated from the projection
definitions declared in `presets.toml` and loaded via `data.py`.
They are no longer hardcoded.
Each preset follows the naming pattern:
'OBLIQUE_#<n>'
where <n> is the index of the preset as defined in `presets.toml`.
A 'CUSTOM' entry is automatically added when the current
projection parameters do not match any predefined preset.
beta : float
The beta angle of the plan (in radians)
alpha : float
The alpha angle of the plan (in radians)
is_updating : bool
Internal flag to prevent infinite update loops during property sync.
rotation : float
Computed rotation around the vertical axis, derived from alpha and beta.
altitude : float
Computed altitude angle, derived from alpha and beta.
altitude_oblique : float
Complementary angle used specifically for oblique projections.
x_value : float
Scaling factor for the X axis (0.0 to 1.0).
normalized_x_value : float
Nomalized scaling factor for the X axis (0.0 to 1.0).
y_value : float
Scaling factor for the Y axis (0.0 to 1.0).
normalized_y_value : float
Nomalized scaling factor for the Y axis (0.0 to 1.0).
z_value : float
Scaling factor for the Z axis (0.0 to 1.0).
normalized_z_value : float
Nomalized scaling factor for the Z axis (0.0 to 1.0).
position : enum
Vertical placement of the camera relative to the scene:
* 'ABOVE' : Standard view from top-down.
* 'BELOW' : View from bottom-up.
orientation : enum
The quadrant orientation (1 to 4) toward which the camera is facing.
ortho_scale : float
The camera's orthographic scale.
"""
bl_idname = "view3d.create_parallel_camera"
bl_label = "Add Camera"
bl_description = "Create a camera using a parallel projection"
bl_options = {"REGISTER", "UNDO"}
projection_type: bpy.props.EnumProperty(
name="Type de Projection",
items=[
("AXONOMETRIC", "Axonometric", "Axonometrics projections", "NONE", 0),
("OBLIQUE", "Oblique", "Obliques projections", "NONE", 1),
],
default="AXONOMETRIC",
) # pyright: ignore[reportInvalidTypeForm]
axonometric_type: bpy.props.EnumProperty(
name="Presets",
description="Type of projection for the new axonometric camera",
items=[
(key, preset["name"], "Presetting an {} camera".format(preset["name"]))
for (key, preset) in CAMERA_SETTINGS.get_preset_menu_items(
"AXONOMETRIC"
).items()
]
+ [("CUSTOM", "Custom", "No camera preselection")],
default="AXONOMETRIC_#1",
) # pyright: ignore[reportInvalidTypeForm]
oblique_type: bpy.props.EnumProperty(
name="Presets",
description="Type of projection for the new oblique camera",
items=[
(key, preset["name"], "Presetting an {} camera".format(preset["name"]))
for (key, preset) in CAMERA_SETTINGS.get_preset_menu_items(
"OBLIQUE"
).items()
]
+ [("CUSTOM", "Custom", "No camera preselection")],
default="OBLIQUE_#1",
) # pyright: ignore[reportInvalidTypeForm]
beta: bpy.props.FloatProperty(
name="Beta",
description="Altitude from the side. 0° is top view, 90° is side view",
default=radians(30),
min=0,
step=1,
precision=4,
max=pi / 2.0,
subtype="ANGLE",
) # pyright: ignore[reportInvalidTypeForm]
alpha: bpy.props.FloatProperty(
name="Alpha",
description="Rotation of the camera around the vertical axis",
default=radians(30),
min=0,
step=1,
precision=4,
max=pi / 2.0,
subtype="ANGLE",
) # pyright: ignore[reportInvalidTypeForm]
is_updating: bpy.props.BoolProperty(default=False) # pyright: ignore[reportInvalidTypeForm]
rotation: bpy.props.FloatProperty(
name="Rotation",
description="Rotation of the camera around the vertical axis",
default=pi / 4.0,
min=0,
precision=4,
max=pi / 2.0,
subtype="ANGLE",
) # pyright: ignore[reportInvalidTypeForm]
altitude: bpy.props.FloatProperty(
name="Altitude",
description="Altitude from the side. 0° is top view, 90° is side view",
default=radians(54.74),
min=0,
precision=4,
max=pi / 2.0,
subtype="ANGLE",
) # pyright: ignore[reportInvalidTypeForm]
altitude_oblique: bpy.props.FloatProperty(name="Altitude", subtype="ANGLE") # pyright: ignore[reportInvalidTypeForm]
x_value: bpy.props.FloatProperty(
name="X axis", description="Size of x axis", default=1, min=0.01, max=1
) # pyright: ignore[reportInvalidTypeForm]
normalized_x_value: bpy.props.FloatProperty(
name="X axis",
description="Size of x axis",
default=1,
min=0.01,
max=1,
) # pyright: ignore[reportInvalidTypeForm]
y_value: bpy.props.FloatProperty(
name="Y axis", description="Size of y axis", default=1, min=0.01, max=1
) # pyright: ignore[reportInvalidTypeForm]
normalized_y_value: bpy.props.FloatProperty(
name="Y axis",
description="Size of y axis",
default=1,
min=0.01,
max=1,
) # pyright: ignore[reportInvalidTypeForm]
z_value: bpy.props.FloatProperty(
name="Z axis",
description="Size of z axis",
step=1,
default=1,
soft_min=0.01,
soft_max=1,
) # pyright: ignore[reportInvalidTypeForm]
normalized_z_value: bpy.props.FloatProperty(
name="Z axis",
description="Size of z axis",
default=1,
soft_min=0.01,
soft_max=1,
) # pyright: ignore[reportInvalidTypeForm]
position: bpy.props.EnumProperty(
name="Type de Projection",
items=[
("ABOVE", "Above", "Put the camera above the scene", "NONE", 0),
("BELOW", "Below", "Put the camera below the scene", "NONE", 1),
],
default="ABOVE",
) # pyright: ignore[reportInvalidTypeForm]
orientation: bpy.props.EnumProperty(
name="Orientation",
items=[
("1", "++", "Oriented towards the 2nd quadrant", "NONE", 0),
("2", "-+", "Oriented towards the 3rd quadrant", "NONE", 1),
("3", "--", "Oriented towards the 4th quadrant", "NONE", 2),
("0", "+-", "Oriented towards the 1st quadrant", "NONE", 3),
],
default="0",
) # pyright: ignore[reportInvalidTypeForm]
ortho_scale: bpy.props.FloatProperty(
name="Orthographic Scale",
description="The camera's orthographic scale",
default=10.000,
precision=3,
min=0.001,
step=10,
) # pyright: ignore[reportInvalidTypeForm]
[docs]
def draw(self, context: bpy.types.Context) -> None:
"""
Draw the operator UI inside Blender popups or menus.
Parameters
---------
context : bpy.types.Context
Blender context
"""
layout = self.layout
draw_create_cam_content(layout, self, "operator")
[docs]
def check(self, context: bpy.types.Context) -> None:
"""
Check if beta, alpha or isOblique values changed to update the camera settings
Parameters
---------
context : bpy.types.Context
Blender context
"""
if self.is_updating:
return False
self.is_updating = True
try:
global \
_beta, \
_alpha, \
_z_value, \
_projection_type, \
_axonometric_type, \
_oblique_type
changed = False
if self.beta != _beta:
camera_attributes_updated(self, "beta")
_beta = self.beta
changed = True
elif self.alpha != _alpha:
camera_attributes_updated(self, "alpha")
changed = True
elif self.z_value != _z_value and self.projection_type == "OBLIQUE":
camera_type = CAMERA_SETTINGS.get_preset_name(
self.projection_type, self.alpha, self.beta, self.z_value
)
self.oblique_type = camera_type
changed = True
elif (
self.projection_type != _projection_type
or self.axonometric_type != _axonometric_type
or self.oblique_type != _oblique_type
):
camera_type_updated(self)
changed = True
if changed:
_alpha = self.alpha
_beta = self.beta
_projection_type = self.projection_type
_axonometric_type = self.axonometric_type
_oblique_type = self.oblique_type
_z_value = self.z_value
return changed
finally:
self.is_updating = False
[docs]
def execute(self, context: bpy.types.Context) -> set[str]:
"""
Execute the camera creation logic.
Creates a new orthographic camera configured according to the selected projection parameters.
Parameters
---------
context : bpy.types.Context
Blender context
Returns
-------
set[str]
Blender operator result status
"""
# area = [a for a in context.screen.areas if a.type == 'VIEW_3D'][0]
# view3d = [space for space in area.spaces if space.type == 'VIEW_3D'][0]
# view3d.show_region_ui = False
bpy.ops.view3d.active_panel_category = False
scn = context.scene
bpy.ops.object.camera_add(
location=(scn.cursor.location), rotation=(0.0, 0.0, 0.0)
)
obj = context.object
isOblique = self.projection_type == "OBLIQUE"
altitude = (
calculate_altitude_with_z_value(self.z_value)
if isOblique
else self.altitude
)
obj.data.type = "ORTHO"
obj.data.ortho_scale = self.ortho_scale
obj.rotation_euler.x = pi / 2.0 + (
altitude * -1 if self.position == "ABOVE" else altitude
)
obj.rotation_euler.z = ((pi / 2) * int(self.orientation)) + self.rotation
obj.location += 25 * obj.rotation_euler.to_matrix().inverted()[2]
if altitude == 0.0 or not isOblique:
pixel_ratio = 1.0
else:
pixel_ratio = 1 / sin(abs(altitude))
obj["pixel_ratio"] = pixel_ratio
scn.render.pixel_aspect_x = pixel_ratio
scn.render.pixel_aspect_y = 1.0
bpy.ops.view3d.object_as_camera()
# self.report({"INFO"}, "Camera created")
return {"FINISHED"}
[docs]
class Pohlke_OT_switch_angles(bpy.types.Operator):
"""Switch Alpha and Beta angle values"""
bl_idname = "view3d.switch_angles"
bl_label = "Switch Alpha/Beta"
bl_options = {"REGISTER", "UNDO"}
[docs]
def execute(self, context):
"""
Perform alpha and beta angle swapping
Parameters
---------
context : bpy.types.Context
Blender context
Returns
-------
set[str]
Blender operator result status
"""
props = context.scene.pohlke_camera_props
alpha_temp = props.alpha
props.alpha = props.beta
props.beta = alpha_temp
self.report({"INFO"}, "Angles switched")
return {"FINISHED"}
# -------------------------------------------------------------------
# HANDLERS
# -------------------------------------------------------------------
@persistent
[docs]
def set_pixel_ratio(scene: bpy.types.Scene) -> None:
"""
Automatically update pixel ratio when the scene updates.
Ensures that orthographic distortion remains consistent when switching cameras or updating the dependency graph.
Parameters
---------
scene : bpy.types.Scene
Current Blender scene
"""
global _last_cam
cam_obj = scene.camera
if cam_obj == _last_cam:
return
if cam_obj is not None and "pixel_ratio" in cam_obj:
ratio = cam_obj["pixel_ratio"]
if abs(scene.render.pixel_aspect_x - ratio) > 0.0001:
scene.render.pixel_aspect_x = ratio
scene.render.pixel_aspect_y = 1.0
_last_cam = cam_obj
# -------------------------------------------------------------------
# REGISTRATION
# -------------------------------------------------------------------
_classes = (Pohlke_OT_CreateCam, Pohlke_OT_switch_angles)
_register, _unregister = bpy.utils.register_classes_factory(_classes)
[docs]
def register() -> None:
"""Register operator and handlers to Blender."""
_register()
bpy.app.handlers.depsgraph_update_pre.append(set_pixel_ratio)
bpy.app.handlers.frame_change_pre.append(set_pixel_ratio)
[docs]
def unregister() -> None:
"""Unregister operator and handlers from Blender."""
_unregister()
bpy.app.handlers.depsgraph_update_pre.remove(set_pixel_ratio)
bpy.app.handlers.frame_change_pre.remove(set_pixel_ratio)