import logging
import math
import xml.etree.ElementTree as ET
from typing import cast
from psd_tools import PSDImage
from psd_tools.api import effects, layers
from psd_tools.constants import Tag
from psd_tools.psd.descriptor import UnitFloat
from psd_tools.terminology import Enum, Key, Klass, Unit
from psd2svg import svg_utils
from psd2svg.core import color_utils
from psd2svg.core.base import ConverterProtocol
from psd2svg.core.gradient import GradientInterpolation
logger = logging.getLogger(__name__)
[docs]
class EffectConverter(ConverterProtocol):
"""Effect converter mixin."""
[docs]
def apply_background_effects(
self, layer: layers.Layer, target: ET.Element, insert_before_target: bool = True
) -> None:
"""Apply background effects to the target element."""
self.apply_drop_shadow_effect(
layer, target, insert_before_target=insert_before_target
)
self.apply_outer_glow_effect(
layer, target, insert_before_target=insert_before_target
)
[docs]
def apply_overlay_effects(self, layer: layers.Layer, target: ET.Element) -> None:
"""Apply overlay effects to the target element."""
self.apply_pattern_overlay_effect(layer, target)
self.apply_gradient_overlay_effect(layer, target)
self.apply_color_overlay_effect(layer, target)
self.apply_inner_shadow_effect(layer, target)
self.apply_inner_glow_effect(layer, target)
self.apply_satin_effect(layer, target)
self.apply_bevel_emboss_effect(layer, target)
[docs]
def apply_color_overlay_effect(
self, layer: layers.Layer, target: ET.Element
) -> None:
"""Apply color overlay effect to the target element."""
effect_list = list(layer.effects.find("coloroverlay", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.ColorOverlay)
if isinstance(layer, layers.ShapeLayer):
use = self.add_vector_color_overlay_effect(effect, target)
else:
use = self.add_raster_color_overlay_effect(effect, target)
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
[docs]
def add_raster_color_overlay_effect(
self, effect: effects.ColorOverlay, target: ET.Element
) -> ET.Element:
"""Add a color overlay filter to the SVG document.
SVG does not allow coloring a raster image directly, so we create a filter.
"""
filter = self.create_node("filter", id=self.auto_id("coloroverlay"))
with self.set_current(filter):
self.create_node(
"feFlood",
flood_color=color_utils.descriptor2hex(effect.color),
)
self.create_node(
"feComposite",
operator="in",
in2="SourceAlpha",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="color-overlay-effect",
)
return use
[docs]
def add_vector_color_overlay_effect(
self, effect: effects.ColorOverlay, target: ET.Element
) -> ET.Element:
"""Add a color overlay effect to the current element using vector path."""
return self.create_node(
"use",
class_="color-overlay-effect",
href=svg_utils.get_uri(target),
fill=color_utils.descriptor2hex(effect.color),
)
[docs]
def apply_stroke_effect(self, layer: layers.Layer, target: ET.Element) -> None:
"""Apply stroke effects to the target element."""
effect_list = list(layer.effects.find("stroke", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.Stroke)
if not isinstance(layer, layers.ShapeLayer) or "stroke" in target.attrib:
# NOTE: If there is already a stroke, we need to stroke around
# the stroke. This case happens when there is a stroke-only shape layer.
use = self.add_raster_stroke_effect(layer, effect, target)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
else:
use = self.add_vector_stroke_effect(layer, effect, target)
# Vector stroke has stroke-opacity attribute. Skip setting opacity.
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
[docs]
def add_raster_stroke_effect(
self, layer: layers.Layer, effect: effects.Stroke, target: ET.Element
) -> ET.Element:
"""Add a stroke filter to the SVG document.
SVG does not allow stroking a raster image directly, so we create a filter.
"""
filter = self.create_node("filter", id=self.auto_id("stroke"))
with self.set_current(filter):
# Create stroke area using morphology and composite.
if effect.position == Enum.OutsetFrame:
self.create_node(
"feMorphology",
operator="dilate",
radius=float(effect.size),
in_="SourceAlpha",
)
self.create_node(
"feComposite",
operator="xor",
in2="SourceAlpha",
result="STROKEAREA",
)
elif effect.position == Enum.InsetFrame:
self.create_node(
"feMorphology",
operator="erode",
radius=float(effect.size),
in_="SourceAlpha",
)
self.create_node(
"feComposite",
operator="xor",
in2="SourceAlpha",
result="STROKEAREA",
)
elif effect.position == Enum.CenteredFrame:
self.create_node(
"feMorphology",
operator="dilate",
radius=float(effect.size) / 2.0,
in_="SourceAlpha",
result="DILATED",
)
self.create_node(
"feMorphology",
operator="erode",
radius=float(effect.size) / 2.0,
in_="SourceAlpha",
result="ERODED",
)
self.create_node(
"feComposite",
operator="xor",
in_="DILATED",
in2="ERODED",
result="STROKEAREA",
)
else:
position_str = (
effect.position.decode()
if isinstance(effect.position, bytes)
else str(effect.position)
)
raise ValueError(f"Unsupported stroke position: {position_str}")
# Gradient and pattern strokes needs feImage.
if effect.fill_type == Enum.SolidColor:
self.create_node(
"feFlood",
flood_color=color_utils.descriptor2hex(effect.color),
)
elif effect.fill_type == Enum.Pattern:
if effect.pattern is None:
raise ValueError("Stroke pattern is None for pattern fill type.")
pattern = self.add_pattern(cast(PSDImage, layer._psd), effect.pattern)
self.set_pattern_effect_transform(pattern, effect, (0, 0))
defs = self.create_node("defs")
with self.set_current(defs):
rect = self.create_node(
"rect",
id=self.auto_id("patternstroke"),
width="100%",
height="100%",
fill=svg_utils.get_funciri(pattern),
)
self.create_node(
"feImage",
href=svg_utils.get_uri(rect),
)
elif effect.fill_type == Enum.GradientFill:
if effect.gradient is None:
raise ValueError("Stroke gradient is None for gradient fill type.")
gradient = None
if effect.type == Enum.Linear:
gradient = self.add_linear_gradient(effect.gradient)
elif effect.type == Enum.Radial:
gradient = self.add_radial_gradient(effect.gradient)
else:
logger.warning(
"Only linear and radial gradient strokes are supported: "
f"{effect}"
)
# Fallback to simple color.
flood_color = (
color_utils.descriptor2hex(effect.color)
if effect.color
else "#ffffff"
)
self.create_node("feFlood", flood_color=flood_color)
if gradient is not None:
self.set_gradient_transform(layer, gradient, effect)
defs = self.create_node("defs")
with self.set_current(defs):
rect = self.create_node(
"rect",
id=self.auto_id("gradientstroke"),
width="100%",
height="100%",
fill=svg_utils.get_funciri(gradient),
)
self.create_node(
"feImage",
href=svg_utils.get_uri(rect),
)
self.create_node(
"feComposite",
operator="in",
in2="STROKEAREA",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="stroke-effect",
)
return use
[docs]
def add_vector_stroke_effect(
self, layer: layers.Layer, effect: effects.Stroke, target: ET.Element
) -> ET.Element:
"""Add a stroke effect to the current element using vector path."""
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
fill="none",
class_="stroke-effect",
)
# Check effect.fill_type.
if effect.fill_type == Enum.SolidColor:
if effect.color is None:
raise ValueError("Stroke color is None for color fill type.")
color = color_utils.descriptor2hex(effect.color)
svg_utils.set_attribute(use, "stroke", color)
elif effect.fill_type == Enum.Pattern:
if effect.pattern is None:
raise ValueError("Stroke pattern is None for pattern fill type.")
pattern = self.add_pattern(cast(PSDImage, layer._psd), effect.pattern)
self.set_pattern_effect_transform(pattern, effect, (0, 0))
svg_utils.set_attribute(use, "stroke", svg_utils.get_funciri(pattern))
elif effect.fill_type == Enum.GradientFill:
if effect.gradient is None:
raise ValueError("Stroke gradient is None for gradient fill type.")
if effect.type == Enum.Linear:
gradient = self.add_linear_gradient(effect.gradient)
elif effect.type == Enum.Radial:
gradient = self.add_radial_gradient(effect.gradient)
else:
logger.warning(
f"Only linear and radial gradient strokes are supported: {effect}"
)
return use
self.set_gradient_transform(layer, gradient, effect)
svg_utils.set_attribute(use, "stroke", svg_utils.get_funciri(gradient))
if effect.opacity != 100.0:
svg_utils.set_attribute(use, "stroke-opacity", effect.opacity)
if float(effect.size) != 1.0:
svg_utils.set_attribute(use, "stroke-width", float(effect.size))
# NOTE: Check position, phase, and offset.
if effect.position != Enum.CenteredFrame:
position = Enum(effect.position) # For validation.
logger.info(
f"Only centered stroke position is supported in SVG: {position.name}"
)
return use
[docs]
def apply_drop_shadow_effect(
self,
layer: layers.Layer,
target: ET.Element,
insert_before_target: bool = False,
) -> None:
"""Apply drop shadow effect to the current element."""
effect_list = list(layer.effects.find("dropshadow", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.DropShadow)
use = self.add_raster_drop_shadow_effect(effect, target)
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
if insert_before_target:
# Push the target element after the <use> element.
self.current.remove(target)
self.current.append(target)
[docs]
def add_raster_drop_shadow_effect(
self, effect: effects.DropShadow, target: ET.Element
) -> ET.Element:
"""Add a drop shadow filter to the SVG document."""
choke = float(effect.choke)
size = float(effect.size)
# NOTE: Adjust the width and height based on size.
filter = self.create_node(
"filter",
id=self.auto_id("dropshadow"),
x="-25%",
y="-25%",
width="150%",
height="150%",
)
with self.set_current(filter):
self.create_node(
"feMorphology",
operator="dilate",
radius=choke / 100.0 * size,
in_="SourceAlpha",
)
self.create_node(
"feGaussianBlur",
stdDeviation=(100.0 - choke) / 100.0 * size / 2.0,
)
dx, dy = polar_to_cartesian(float(effect.angle), float(effect.distance))
self.create_node(
"feOffset",
dx=dx,
dy=dy,
result="SHADOW",
)
self.create_node(
"feFlood",
flood_color=color_utils.descriptor2hex(effect.color),
)
self.create_node(
"feComposite",
operator="in",
in2="SHADOW",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="drop-shadow-effect",
)
return use
[docs]
def apply_outer_glow_effect(
self,
layer: layers.Layer,
target: ET.Element,
insert_before_target: bool = False,
) -> None:
"""Apply outer glow effect to the current element."""
effect_list = list(layer.effects.find("outerglow", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.OuterGlow)
use = self.add_raster_outer_glow_effect(effect, target)
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
if insert_before_target:
# Push the target element after the <use> element.
self.current.remove(target)
self.current.append(target)
[docs]
def add_raster_outer_glow_effect(
self, effect: effects.OuterGlow, target: ET.Element
) -> ET.Element:
"""Add an outer glow filter to the SVG document."""
choke = float(effect.choke)
size = float(effect.size)
# NOTE: Adjust the width and height based on size.
filter = self.create_node(
"filter",
id=self.auto_id("outerglow"),
)
# NOTE: Adjust radius and stdDeviation, as the rendering quality differs.
with self.set_current(filter):
self.create_node(
"feMorphology",
operator="dilate",
radius=choke / 100.0 * size + (100.0 - choke) / 100.0 * size / 6.0,
in_="SourceAlpha",
)
self.create_node(
"feGaussianBlur",
stdDeviation=(100.0 - choke) / 100.0 * size / 4.0,
result="GLOW",
)
self.create_node(
"feFlood",
flood_color=color_utils.descriptor2hex(effect.color),
)
self.create_node(
"feComposite",
operator="in",
in2="GLOW",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="outer-glow-effect",
)
return use
[docs]
def apply_gradient_overlay_effect(
self, layer: layers.Layer, target: ET.Element
) -> None:
effect_list = list(layer.effects.find("gradientoverlay", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.GradientOverlay)
if effect.type == Enum.Linear:
gradient = self.add_linear_gradient(effect.gradient)
elif effect.type == Enum.Radial:
gradient = self.add_radial_gradient(effect.gradient)
else:
effect_type_str = (
effect.type.decode()
if isinstance(effect.type, bytes)
else str(effect.type)
)
logger.warning(
"Only linear and radial gradient overlay are supported: "
f"{effect_type_str}: '{layer.name}' ({layer.kind})"
)
continue
self.set_gradient_transform(layer, gradient, effect)
if isinstance(layer, layers.ShapeLayer):
use = self.add_vector_gradient_overlay_effect(gradient, target)
else:
use = self.add_raster_gradient_overlay_effect(gradient, target, effect)
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
[docs]
def add_raster_gradient_overlay_effect(
self, gradient: ET.Element, target: ET.Element, effect: effects.GradientOverlay
) -> ET.Element:
# feFlood does not support fill with gradient,
# so we use feImage and feComposite.
defs = self.create_node("defs")
# Rect here should have the target size.
with self.set_current(defs):
rect = self.create_node(
"rect",
id=self.auto_id("gradientfill"),
width=target.get("width", "100%"),
height=target.get("height", "100%"),
fill=svg_utils.get_funciri(gradient),
)
# When gradient is aligned to layer bounds (Aligned=True), use objectBoundingBox
# coordinates for the filter. Otherwise use userSpaceOnUse.
# Note: The key is b'Algn', not Key.Aligned (which is b'Algd')
aligned = bool(effect.value.get(b"Algn", False))
filter_attrs = {"id": self.auto_id("gradientoverlay")}
if aligned:
# Use objectBoundingBox coordinates
# (filter positioned relative to target element)
filter_attrs["x"] = "0%"
filter_attrs["y"] = "0%"
filter_attrs["width"] = "100%"
filter_attrs["height"] = "100%"
filter_attrs["filterUnits"] = "objectBoundingBox"
else:
# Use userSpaceOnUse coordinates (filter positioned in absolute coordinates)
# Get target position if available
if "x" in target.attrib and "y" in target.attrib:
x_val = target.get("x")
y_val = target.get("y")
filter_attrs["x"] = x_val if x_val is not None else "0"
filter_attrs["y"] = y_val if y_val is not None else "0"
else:
filter_attrs["x"] = "0"
filter_attrs["y"] = "0"
filter_attrs["filterUnits"] = "userSpaceOnUse"
filter = self.create_node("filter", id=filter_attrs["id"])
for key, value in filter_attrs.items():
if key != "id":
svg_utils.set_attribute(filter, key, value)
with self.set_current(filter):
self.create_node(
"feImage",
href=svg_utils.get_uri(rect),
)
self.create_node(
"feComposite",
in2="SourceAlpha",
operator="in",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="gradient-overlay-effect",
)
return use
[docs]
def add_vector_gradient_overlay_effect(
self, gradient: ET.Element, target: ET.Element
) -> ET.Element:
return self.create_node(
"use",
parent=self.current,
class_="gradient-overlay-effect",
href=svg_utils.get_uri(target),
fill=svg_utils.get_funciri(gradient),
)
[docs]
def apply_pattern_overlay_effect(
self, layer: layers.Layer, target: ET.Element
) -> None:
effect_list = list(layer.effects.find("patternoverlay", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.PatternOverlay)
pattern = self.add_pattern(cast(PSDImage, layer._psd), effect.pattern)
reference = layer.tagged_blocks.get_data(Tag.REFERENCE_POINT, (0, 0))
self.set_pattern_effect_transform(pattern, effect, reference)
if isinstance(layer, layers.ShapeLayer):
use = self.add_vector_pattern_overlay_effect(pattern, target)
else:
use = self.add_raster_pattern_overlay_effect(pattern, target)
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
[docs]
def add_raster_pattern_overlay_effect(
self, pattern: ET.Element, target: ET.Element
) -> ET.Element:
# feFlood does not support fill with pattern, so we use feImage and feComposite.
defs = self.create_node("defs")
if "x" not in target.attrib or "y" not in target.attrib:
logger.debug(
"Target element for raster pattern overlay effect "
"does not have 'x' or 'y' attribute. "
"Assuming (0, 0) as the origin."
)
# Rect here should have the target size and location.
with self.set_current(defs):
rect = self.create_node(
"rect",
id=self.auto_id("patternfill"),
x=target.get("x", "0"),
y=target.get("y", "0"),
width=target.get("width", "100%"),
height=target.get("height", "100%"),
fill=svg_utils.get_funciri(pattern),
)
# Filter should use the user space coordinates here.
filter = self.create_node(
"filter",
id=self.auto_id("patternoverlay"),
x=0,
y=0,
filterUnits="userSpaceOnUse",
)
with self.set_current(filter):
self.create_node(
"feImage",
href=svg_utils.get_uri(rect),
)
self.create_node(
"feComposite",
in2="SourceAlpha",
operator="in",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="pattern-overlay-effect",
)
return use
[docs]
def add_vector_pattern_overlay_effect(
self, pattern: ET.Element, target: ET.Element
) -> ET.Element:
return self.create_node(
"use",
parent=self.current,
class_="pattern-overlay-effect",
href=svg_utils.get_uri(target),
fill=svg_utils.get_funciri(pattern),
)
[docs]
def apply_inner_shadow_effect(
self, layer: layers.Layer, target: ET.Element
) -> None:
effect_list = list(layer.effects.find("innershadow", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.InnerShadow)
use = self.add_raster_inner_shadow_effect(effect, target)
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
[docs]
def add_raster_inner_shadow_effect(
self, effect: effects.InnerShadow, target: ET.Element
) -> ET.Element:
"""Add an inner shadow filter to the SVG document."""
logger.debug(f"Adding raster inner shadow effect: {effect}")
choke = float(effect.choke)
size = float(effect.size)
filter = self.create_node(
"filter",
id=self.auto_id("innershadow"),
)
with self.set_current(filter):
self.create_node(
"feMorphology",
operator="erode",
radius=choke / 100.0 * size,
in_="SourceAlpha",
)
self.create_node(
"feGaussianBlur",
stdDeviation=(100.0 - choke) / 100.0 * size / 2.0,
)
dx, dy = polar_to_cartesian(float(effect.angle), float(effect.distance))
self.create_node(
"feOffset",
dx=dx,
dy=dy,
result="SHADOW",
)
self.create_node(
"feFlood",
flood_color=color_utils.descriptor2hex(effect.color),
)
self.create_node(
"feComposite",
operator="out",
in2="SHADOW",
)
# Restrict the shadow to the inside of the original shape.
self.create_node(
"feComposite",
operator="in",
in2="SourceAlpha",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="inner-shadow-effect",
)
return use
[docs]
def apply_inner_glow_effect(self, layer: layers.Layer, target: ET.Element) -> None:
effect_list = list(layer.effects.find("innerglow", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.InnerGlow)
use = self.add_raster_inner_glow_effect(effect, target)
if effect.blend_mode != Enum.Normal:
self.set_blend_mode(effect.blend_mode, use)
if effect.opacity != 100.0:
self.set_opacity(effect.opacity / 100.0, use)
[docs]
def add_raster_inner_glow_effect(
self, effect: effects.InnerGlow, target: ET.Element
) -> ET.Element:
"""Add an inner glow filter to the SVG document."""
# TODO: Support different glow types.
if effect.glow_type != Enum.SoftMatte:
glow_type_str = (
effect.glow_type.decode()
if isinstance(effect.glow_type, bytes)
else str(effect.glow_type)
)
logger.warning(f"Only softer inner glow is supported: {glow_type_str}")
choke = float(effect.choke)
size = float(effect.size)
# Determine glow color: either solid color or gradient
glow_color = "none"
if effect.color is not None:
# Solid color glow
glow_color = color_utils.descriptor2hex(effect.color)
elif effect.gradient is not None:
# Gradient-based glow: use first color stop as approximation
# TODO: Support full gradient rendering for inner glow
logger.warning(
"Gradient-based inner glow is approximated using first color stop"
)
try:
grad_interp = GradientInterpolation(effect.gradient)
if grad_interp.color_stops:
first_color = grad_interp.color_stops[0][1]
glow_color = color_utils.descriptor2hex(first_color)
except Exception as e:
logger.warning(f"Failed to extract gradient color for inner glow: {e}")
glow_color = "none"
# NOTE: Adjust the width and height based on size.
filter = self.create_node(
"filter",
id=self.auto_id("innerglow"),
)
# NOTE: Adjust radius and stdDeviation, as the rendering quality differs.
with self.set_current(filter):
self.create_node(
"feMorphology",
operator="erode",
radius=choke / 100.0 * size + (100.0 - choke) / 100.0 * size / 6.0,
in_="SourceAlpha",
)
self.create_node(
"feGaussianBlur",
stdDeviation=(100.0 - choke) / 100.0 * size / 4.0,
result="GLOW",
)
self.create_node(
"feFlood",
flood_color=glow_color,
)
self.create_node(
"feComposite",
operator="out" if effect.glow_source == Enum.EdgeGlow else "in",
in2="GLOW",
)
self.create_node(
"feComposite",
operator="in",
in2="SourceAlpha",
)
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
filter=svg_utils.get_funciri(filter),
class_="inner-glow-effect",
)
return use
[docs]
def apply_satin_effect(self, layer: layers.Layer, target: ET.Element) -> None:
effect_list = list(layer.effects.find("satin", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.Satin)
logger.warning(
f"Satin effect is not supported yet: '{layer.name}' ({layer.kind})"
)
[docs]
def apply_bevel_emboss_effect(
self, layer: layers.Layer, target: ET.Element
) -> None:
effect_list = list(layer.effects.find("bevelemboss", enabled=True))
for effect in reversed(effect_list):
assert isinstance(effect, effects.BevelEmboss)
logger.warning(
f"Bevel emboss effect is not supported yet: "
f"'{layer.name}' ({layer.kind})"
)
[docs]
def polar_to_cartesian(angle: float, distance: float) -> tuple[float, float]:
"""Convert the polar coordinate to dx and dy."""
angle_rad = angle * math.pi / 180.0
return -distance * math.cos(angle_rad), distance * math.sin(angle_rad)