Write docs for this:
```
import json
import pyglet
import math
from pyglet.gl import GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA
from utils import Trig

pyglet.gl.glEnable(pyglet.gl.GL_BLEND)
pyglet.gl.glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

class SceneLoader:
    """
    Loads scene elements from data, creating Pyglet objects.
    Supports templates, macros, parenting, explicit pivots, scaling, and click events.
    """

    def __init__(self, batch, window):
        """
        Initializes the SceneLoader.

        Args:
            batch (pyglet.graphics.Batch): The batch for drawable objects.
            window (pyglet.window.Window): The Pyglet window for event handling and height.
        """
        if not isinstance(batch, pyglet.graphics.Batch):
            raise TypeError("batch must be a pyglet.graphics.Batch instance")
        if not isinstance(window, pyglet.window.Window):
            raise TypeError("window must be a pyglet.window.Window instance")

        self.batch = batch
        self.window = window
        self.window_height = window.height
        self.created_elements = []
        self.named_elements = {}
        self._element_configs = {}
        self._world_transforms = {}
        self._definitions = {}

        # Register mouse event handler
        self.window.push_handlers(on_mouse_press=self.on_mouse_press)

    def _get_color_and_opacity(self, data):
        """Helper to parse color and opacity from element data."""
        color_data = data.get("color", [255, 255, 255, 255])
        if not (isinstance(color_data, list) and (3 <= len(color_data) <= 4) and
                all(isinstance(c, int) and 0 <= c <= 255 for c in color_data)):
            raise ValueError(f"Invalid color: {color_data}. Must be [R,G,B] or [R,G,B,A].")

        rgb = tuple(color_data[:3])
        alpha_from_color = color_data[3] if len(color_data) == 4 else 255
        opacity_override = data.get("opacity")
        if opacity_override is not None:
            if not (isinstance(opacity_override, (int, float)) and 0.0 <= opacity_override <= 1.0):
                raise ValueError(f"Invalid opacity: {opacity_override}. Must be 0.0-1.0.")
            final_alpha = int(opacity_override * 255)
        else:
            final_alpha = alpha_from_color
        return rgb, final_alpha

    def _get_stroke_properties(self, data):
        """Helper to parse stroke properties."""
        stroke_color_data = data.get("stroke_color")
        stroke_rgb = None
        stroke_alpha = 255

        if stroke_color_data:
            if not (isinstance(stroke_color_data, list) and
                    (3 <= len(stroke_color_data) <= 4) and
                    all(isinstance(c, int) and 0 <= c <= 255 for c in stroke_color_data)):
                raise ValueError(f"Invalid stroke_color: {stroke_color_data}.")
            stroke_rgb = tuple(stroke_color_data[:3])
            if len(stroke_color_data) == 4:
                stroke_alpha = stroke_color_data[3]

        stroke_width = data.get("stroke_width", 0.0)
        if not isinstance(stroke_width, (int, float)) or stroke_width < 0:
            raise ValueError(f"Invalid stroke_width: {stroke_width}. Must be non-negative.")
        return stroke_rgb, stroke_alpha, stroke_width

    def _create_pyglet_object(self, config):
        """Creates a Pyglet drawable object from a processed config."""
        element_type = config.get("type")
        if not element_type:
            raise ValueError("Element config missing 'type'.")

        rgb, final_alpha = self._get_color_and_opacity(config)
        stroke_rgb, stroke_alpha, stroke_width = self._get_stroke_properties(config)
        pyglet_obj = None

        config['_original_width'] = config.get("size", [0,0])[0] if "size" in config and isinstance(config.get("size"), list) and len(config["size"])==2 else 0
        config['_original_height'] = config.get("size", [0,0])[1] if "size" in config and isinstance(config.get("size"), list) and len(config["size"])==2 else 0
        config['_original_radius'] = config.get("radius", 0) if element_type == "shape" and config.get("shape_type") == "circle" else 0

        if element_type == "shape":
            shape_type = config.get("shape_type")
            if not shape_type:
                raise ValueError("Shape element config missing 'shape_type'.")

            if shape_type in ["rectangle", "rounded_rectangle", "circle", "triangle", "polygon"] and stroke_width > 0:
                print(f"Warning: stroke_width > 0 for '{config.get('name', 'unnamed')} ({shape_type})'. "
                      "Pyglet.shapes objects do not directly support distinct strokes. "
                      "Stroke will be ignored for fill, consider using 'line' type for outlines.")

            if shape_type == "rectangle":
                if "size" not in config: raise ValueError("Rectangle config missing 'size'.")
                width, height = config["size"]
                pyglet_obj = pyglet.shapes.Rectangle(x=0, y=0, width=width, height=height, color=rgb, batch=self.batch)

            elif shape_type == "rounded_rectangle":
                if "size" not in config: raise ValueError("RoundedRectangle config missing 'size'.")
                width, height = config["size"]
                radius_data = config.get("radius", 0)
                pyglet_obj = pyglet.shapes.RoundedRectangle(x=0, y=0, width=width, height=height, radius=radius_data, color=rgb, batch=self.batch)

            elif shape_type == "circle":
                if "radius" not in config: raise ValueError("Circle config missing 'radius'.")
                radius = config["radius"]
                pyglet_obj = pyglet.shapes.Circle(x=0, y=0, radius=radius, color=rgb, batch=self.batch)

            elif shape_type == "triangle":
                if "vertices" not in config: raise ValueError("Triangle config missing 'vertices'.")
                v_local = config["vertices"]
                if not (isinstance(v_local, list) and len(v_local) == 3 and all(isinstance(v, (list, tuple)) and len(v) == 2 for v in v_local)):
                    raise ValueError("Triangle 'vertices' must be a list of three [x,y] pairs.")

                config['_original_vertices'] = [list(v) for v in v_local]
                pyglet_obj = pyglet.shapes.Triangle(
                    v_local[0][0], self.window_height - v_local[0][1],
                    v_local[1][0], self.window_height - v_local[1][1],
                    v_local[2][0], self.window_height - v_local[2][1],
                    color=rgb, batch=self.batch
                )

            elif shape_type == "line":
                start = config.get("start")
                end = config.get("end")
                vertices = config.get("vertices")
                if vertices:
                    if not (isinstance(vertices, list) and len(vertices) == 2 and
                            all(isinstance(v, (list, tuple)) and len(v) == 2 for v in vertices)):
                        raise ValueError("Line 'vertices' must be a list of two [x,y] pairs.")
                    start = vertices[0]
                    end = vertices[1]

                if not start or not end: raise ValueError("Line config missing 'start' and 'end' points (or 'vertices').")

                line_color = rgb if not stroke_rgb else stroke_rgb
                line_alpha = final_alpha if not stroke_rgb else stroke_alpha
                line_width = stroke_width if stroke_width > 0 else 1.0

                pyglet_obj = pyglet.shapes.Line(
                    start[0], self.window_height - start[1],
                    end[0], self.window_height - end[1],
                    line_width,
                    color=(*line_color, line_alpha), batch=self.batch
                )
                config['_original_start'] = list(start)
                config['_original_end'] = list(end)

            elif shape_type == "polygon":
                if "vertices" not in config: raise ValueError("Polygon config missing 'vertices'.")
                v_local = config["vertices"]
                if not (isinstance(v_local, list) and len(v_local) >= 3 and all(isinstance(v, (list, tuple)) and len(v) == 2 for v in v_local)):
                    raise ValueError("Polygon 'vertices' must be a list of at least three [x,y] pairs.")

                config['_original_vertices'] = [list(v) for v in v_local]
                flat_vertices_pyglet = []
                for vx, vy in v_local:
                    flat_vertices_pyglet.extend((vx, self.window_height - vy))
                pyglet_obj = pyglet.shapes.Polygon(*flat_vertices_pyglet, color=rgb, batch=self.batch)

            else:
                raise ValueError(f"Unknown shape_type: {shape_type}")

        elif element_type == "text":
            text_content = config.get("text", "")
            font_name = config.get("font_name", "Arial")
            font_size = config.get("font_size", 12)
            text_color_rgba = (*rgb, final_alpha)
            pyglet_obj = pyglet.text.Label(
                text=text_content, font_name=font_name, font_size=font_size,
                color=text_color_rgba, x=0, y=0,
                anchor_x=config.get("anchor_x", "left"),
                anchor_y=config.get("anchor_y", "top"),
                batch=self.batch
            )
            config['_original_font_size'] = font_size

        else:
            raise ValueError(f"Unknown element type: {element_type}")

        if pyglet_obj:
            if not isinstance(pyglet_obj, pyglet.text.Label):
                pyglet_obj.opacity = final_alpha
            if hasattr(pyglet_obj, "x") and hasattr(pyglet_obj, "y"):
                pyglet_obj.x = 0
                pyglet_obj.y = 0
            if hasattr(pyglet_obj, "rotation"):
                pyglet_obj.rotation = 0
        return pyglet_obj

    def _process_single_element_template(self, element_data):
        """Applies template data and processes a single element dictionary."""
        processed_data = {}
        template_name = element_data.get("template")
        if template_name:
            template_config = self._definitions.get(template_name)
            if template_config is None: raise ValueError(f"Undefined template '{template_name}'.")
            if isinstance(template_config, list): raise TypeError(f"Template '{template_name}' is a macro.")
            if not isinstance(template_config, dict): raise TypeError(f"Template '{template_name}' not a dict.")
            processed_data.update(template_config)

        processed_data.update(element_data)
        if "template" in processed_data: del processed_data["template"]
        return processed_data

    def _calculate_world_transform(self, element_name):
        """Recursively calculates world_pos, world_rot, world_scale for the element's pivot."""
        if element_name in self._world_transforms:
            return self._world_transforms[element_name]

        config = self._element_configs.get(element_name)
        if config is None:
            raise ValueError(f"Config not found for '{element_name}'.")

        local_pos_x, local_pos_y = config.get("position", [0.0, 0.0])
        local_rotation = config.get("rotation", 0.0)
        s = config.get("scale", 1.0)
        local_scale_x = config.get("scale_x", s)
        local_scale_y = config.get("scale_y", s)

        parent_name = config.get("parent")
        parent_world_pos = (0.0, 0.0)
        parent_world_rot = 0.0
        parent_world_scale = (1.0, 1.0)

        if parent_name:
            try:
                parent_world_pos, parent_world_rot, parent_world_scale = self._calculate_world_transform(parent_name)
            except ValueError as e:
                raise ValueError(f"Failed to calc parent '{parent_name}' transform.") from e

        macro_base_pos_x, macro_base_pos_y = config.get("_macro_base_position", [0.0, 0.0])
        macro_base_rotation = config.get("_macro_base_rotation", 0.0)
        mbsx = config.get("_macro_base_scale_x", 1.0)
        mbsy = config.get("_macro_base_scale_y", 1.0)

        el_pos_rel_macro_x, el_pos_rel_macro_y = local_pos_x, local_pos_y
        el_rot_rel_macro = local_rotation
        el_scale_rel_macro_x, el_scale_rel_macro_y = local_scale_x, local_scale_y

        pos_in_macro_x = el_pos_rel_macro_x * mbsx
        pos_in_macro_y = el_pos_rel_macro_y * mbsy

        pos_rel_parent_x, pos_rel_parent_y = Trig.rotate_point(
            pos_in_macro_x, pos_in_macro_y, macro_base_rotation, 0, 0
        )
        pos_rel_parent_x += macro_base_pos_x
        pos_rel_parent_y += macro_base_pos_y

        rot_rel_parent = el_rot_rel_macro + macro_base_rotation
        scale_rel_parent_x = el_scale_rel_macro_x * mbsx
        scale_rel_parent_y = el_scale_rel_macro_y * mbsy

        final_pos_x = pos_rel_parent_x * parent_world_scale[0]
        final_pos_y = pos_rel_parent_y * parent_world_scale[1]

        world_pos_x, world_pos_y = Trig.rotate_point(
            final_pos_x, final_pos_y, parent_world_rot, 0, 0
        )
        world_pos_x += parent_world_pos[0]
        world_pos_y += parent_world_pos[1]

        world_rotation = rot_rel_parent + parent_world_rot
        world_scale_x = scale_rel_parent_x * parent_world_scale[0]
        world_scale_y = scale_rel_parent_y * parent_world_scale[1]

        world_transform = ((world_pos_x, world_pos_y), world_rotation, (world_scale_x, world_scale_y))
        self._world_transforms[element_name] = world_transform
        return world_transform

    def _apply_world_transform(self, element_name):
        """Apply calculated world position and rotation to the Pyglet object."""
        pyglet_obj = self.named_elements.get(element_name)
        if pyglet_obj is None:
            for obj_iter in self.created_elements:
                if hasattr(obj_iter, '_internal_name') and obj_iter._internal_name == element_name:
                    pyglet_obj = obj_iter
                    break

        config = self._element_configs.get(element_name)
        if config is None or pyglet_obj is None: return

        transform_data = self._world_transforms.get(element_name)
        if transform_data is None: return

        world_pos, world_rotation, world_scale = transform_data
        element_type = config.get("type")

        default_pivot = [0.0, 0.0]
        shape_type = config.get("shape_type")
        if shape_type == "rectangle" or shape_type == "rounded_rectangle":
             default_pivot = [0.0, 0.0]
        elif shape_type == "circle":
             default_pivot = [0.0, 0.0]
        elif shape_type in ["triangle", "polygon", "line"]:
             default_pivot = [0.0, 0.0]

        local_pivot = config.get("pivot", default_pivot)

        if element_type == "shape":
            pyglet_obj.x = world_pos[0]
            pyglet_obj.y = self.window_height - world_pos[1]
            pyglet_obj.rotation = -math.degrees(world_rotation)

            if shape_type in ["rectangle", "rounded_rectangle"]:
                original_width = config.get('_original_width', 0)
                original_height = config.get('_original_height', 0)
                final_width = original_width * world_scale[0]
                final_height = original_height * world_scale[1]
                pyglet_obj.width = final_width
                pyglet_obj.height = final_height
                pyglet_obj.anchor_x = local_pivot[0] * final_width
                pyglet_obj.anchor_y = (1.0 - local_pivot[1]) * final_height

            elif shape_type == "circle":
                original_radius = config.get('_original_radius', 0)
                final_radius = original_radius * ((abs(world_scale[0]) + abs(world_scale[1])) / 2.0)
                pyglet_obj.radius = final_radius
                pyglet_obj.anchor_x = local_pivot[0] * world_scale[0]
                pyglet_obj.anchor_y = local_pivot[1] * world_scale[1]

            elif shape_type in ["triangle", "polygon"]:
                original_vertices = config.get("_original_vertices", [])
                transformed_vertices = []
                for vx, vy in original_vertices:
                    v_rel_pivot_x = vx - local_pivot[0]
                    v_rel_pivot_y = vy - local_pivot[1]
                    v_scaled_x = v_rel_pivot_x * world_scale[0]
                    v_scaled_y = v_rel_pivot_y * world_scale[1]
                    v_rot_x, v_rot_y = Trig.rotate_point(v_scaled_x, v_scaled_y, world_rotation, 0, 0)
                    v_world_x = v_rot_x + world_pos[0]
                    v_world_y = v_rot_y + world_pos[1]
                    transformed_vertices.append((v_world_x, self.window_height - v_world_y))

                flat_verts = [coord for v_pair in transformed_vertices for coord in v_pair]

                if shape_type == "triangle" and len(transformed_vertices) == 3:
                     pyglet_obj.x, pyglet_obj.y = transformed_vertices[0]
                     pyglet_obj.x2, pyglet_obj.y2 = transformed_vertices[1]
                     pyglet_obj.x3, pyglet_obj.y3 = transformed_vertices[2]
                elif shape_type == "polygon" and len(transformed_vertices) >= 3:
                    try:
                        pyglet_obj.vertices = tuple(flat_verts)
                    except Exception as e:
                        print(f"Note: Could not update vertices for polygon '{element_name}': {e}.")

            elif shape_type == "line":
                original_start = config.get('_original_start', [0,0])
                original_end = config.get('_original_end', [0,0])
                points_to_transform = [original_start, original_end]
                transformed_line_points = []
                for vx, vy in points_to_transform:
                    v_rel_pivot_x = vx - local_pivot[0]
                    v_rel_pivot_y = vy - local_pivot[1]
                    v_scaled_x = v_rel_pivot_x * world_scale[0]
                    v_scaled_y = v_rel_pivot_y * world_scale[1]
                    v_rot_x, v_rot_y = Trig.rotate_point(v_scaled_x, v_scaled_y, world_rotation, 0, 0)
                    v_world_x = v_rot_x + world_pos[0]
                    v_world_y = v_rot_y + world_pos[1]
                    transformed_line_points.append((v_world_x, self.window_height - v_world_y))

                if len(transformed_line_points) == 2:
                    pyglet_obj.x, pyglet_obj.y = transformed_line_points[0]
                    pyglet_obj.x2, pyglet_obj.y2 = transformed_line_points[1]

        elif element_type == "text":
            pyglet_obj.x = world_pos[0]
            pyglet_obj.y = self.window_height - world_pos[1]
            original_font_size = config.get("_original_font_size", 12)
            pyglet_obj.font_size = original_font_size * ((abs(world_scale[0]) + abs(world_scale[1])) / 2.0)
            if world_rotation != 0.0:
                print(f"Warning: Rotation not supported for text label '{element_name}'.")

    def load_scene_from_data(self, scene_source_data, click_handlers=None):
        """Loads elements from a dictionary or list into the batch with optional click handlers."""
        if click_handlers is None:
            click_handlers = {}
        self.clear_elements()
        element_list = None
        if isinstance(scene_source_data, dict):
            self._definitions = scene_source_data.get("definitions", {})
            element_list = scene_source_data.get("scene")
            if element_list is None: raise ValueError("Scene data dict must contain 'scene' key.")
            if not isinstance(self._definitions, dict): raise TypeError("'definitions' must be a dict.")
            if not isinstance(element_list, list): raise TypeError("'scene' must be a list.")
        elif isinstance(scene_source_data, list):
            element_list = scene_source_data
        else:
            raise TypeError("scene_source_data must be a dict or a list.")

        macro_instance_counter = 0
        temp_name_counter = 0
        elements_to_process_info = []

        for i, element_data_source in enumerate(element_list):
            if not isinstance(element_data_source, dict):
                print(f"Warning: Element at index {i} not a dict, skipping.")
                continue
            try:
                element_data = element_data_source.copy()
                template_name = element_data.get("template")
                defn = self._definitions.get(template_name) if template_name else None

                if template_name and isinstance(defn, list):
                    macro_instance_name_base = element_data.get("name", f"__macro_{macro_instance_counter}__")
                    macro_instance_name = macro_instance_name_base
                    unique_counter = 0
                    while macro_instance_name in self._element_configs or \
                          any(info[0].startswith(macro_instance_name + "__") for info in elements_to_process_info):
                        macro_instance_name = f"{macro_instance_name_base}_{unique_counter}"
                        unique_counter += 1
                    macro_instance_counter += 1

                    base_parent = element_data.get("parent")
                    base_pos = element_data.get("position", [0.0, 0.0])
                    base_rot = element_data.get("rotation", 0.0)
                    base_s = element_data.get("scale", 1.0)
                    base_scale_x = element_data.get("scale_x", base_s)
                    base_scale_y = element_data.get("scale_y", base_s)
                    inst_macro_base_scale_props = {
                        "_macro_base_position": base_pos,
                        "_macro_base_rotation": base_rot,
                        "_macro_base_scale_x": base_scale_x,
                        "_macro_base_scale_y": base_scale_y,
                        "parent": base_parent
                    }

                    for j, macro_element_def_source in enumerate(defn):
                        if not isinstance(macro_element_def_source, dict): continue
                        macro_element_def = macro_element_def_source.copy()

                        if "template" in macro_element_def:
                            try:
                                resolved_macro_elem_config = self._process_single_element_template(macro_element_def)
                            except (ValueError, TypeError) as e:
                                print(f"Error resolving template in macro element index {j} of macro '{template_name}': {e}.")
                                continue
                        else:
                            resolved_macro_elem_config = macro_element_def

                        if "template" in resolved_macro_elem_config:
                            del resolved_macro_elem_config["template"]

                        if "parent" in resolved_macro_elem_config:
                            print(f"Warning: Parent property '{resolved_macro_elem_config['parent']}' ignored in macro element.")
                            del resolved_macro_elem_config["parent"]

                        macro_el_orig_name = resolved_macro_elem_config.get('name')
                        internal_name = f"{macro_instance_name}__{macro_el_orig_name if macro_el_orig_name else f'item_{j}'}"

                        if internal_name in self._element_configs:
                            print(f"Warning: Generated internal name '{internal_name}' clashes in macro. Skipping.")
                            continue

                        resolved_macro_elem_config.update(inst_macro_base_scale_props)
                        self._element_configs[internal_name] = resolved_macro_elem_config
                        elements_to_process_info.append((internal_name, macro_el_orig_name))

                else:
                    processed_data = self._process_single_element_template(element_data) if template_name else element_data.copy()
                    original_name = processed_data.get("name")
                    internal_name = original_name if original_name else f"__unnamed_{temp_name_counter}__"

                    if not original_name:
                        unique_counter = 0
                        temp_base = internal_name
                        while internal_name in self._element_configs:
                           internal_name = f"{temp_base}_{unique_counter}"
                           unique_counter +=1

                    if internal_name in self._element_configs and original_name:
                         print(f"Warning: Duplicate element name '{internal_name}'. Overwriting.")

                    self._element_configs[internal_name] = processed_data
                    elements_to_process_info.append((internal_name, original_name))
                    if not original_name: temp_name_counter += 1

            except (KeyError, ValueError, TypeError) as e:
                el_name_src = element_data_source.get("name", f"element at index {i}")
                print(f"Error processing {el_name_src}: {e}. Skipping element.")

        for internal_name, _ in elements_to_process_info:
            try:
                self._calculate_world_transform(internal_name)
            except ValueError as e:
                orig_name_disp = self._element_configs[internal_name].get('name', internal_name) if internal_name in self._element_configs else internal_name
                print(f"Error calculating world transform for '{orig_name_disp}': {e}.")
                if internal_name in self._world_transforms: del self._world_transforms[internal_name]

        self.named_elements.clear()
        valid_elements_to_create = [info for info in elements_to_process_info if info[0] in self._element_configs and info[0] in self._world_transforms]

        for internal_name, original_name in valid_elements_to_create:
            config = self._element_configs.get(internal_name)
            try:
                pyglet_obj = self._create_pyglet_object(config)
                if pyglet_obj:
                    pyglet_obj._internal_name = internal_name
                    self.created_elements.append(pyglet_obj)
                    if original_name:
                        if original_name in self.named_elements:
                             print(f"Warning: Duplicate explicit name '{original_name}'. Overwriting.")
                        self.named_elements[original_name] = pyglet_obj

                    if "on_click" in config and config["on_click"] in click_handlers:
                        pyglet_obj.on_click = click_handlers[config["on_click"]]

                    self._apply_world_transform(internal_name)

                else:
                    el_id = original_name if original_name else internal_name
                    print(f"Warning: Pyglet object creation returned None for {el_id}.")

            except (KeyError, ValueError, TypeError) as e:
                el_id = original_name if original_name else internal_name
                print(f"Error creating/positioning pyglet object for {el_id}: {e}.")

        for name in list(self._element_configs.keys()):
            if name.startswith("__unnamed_"):
                del self._element_configs[name]
                if name in self._world_transforms: del self._world_transforms[name]

        return list(self.created_elements)

    def load_scene_from_file(self, filepath):
        """Loads scene data from a JSON file."""
        try:
            with open(filepath, "r") as f: data = json.load(f)
            return self.load_scene_from_data(data)
        except FileNotFoundError: print(f"Error: Scene file not found at '{filepath}'."); raise
        except json.JSONDecodeError as e: print(f"Error: JSON decode error in '{filepath}': {e}"); raise

    def clear_elements(self):
        """Deletes created Pyglet elements and clears internal state."""
        for element in self.created_elements:
            if element.batch:
                try: element.delete()
                except Exception as e: print(f"Warning: Could not delete {element}: {e}")
        self.created_elements.clear()
        self.named_elements.clear()
        self._element_configs.clear()
        self._world_transforms.clear()
        self._definitions.clear()

    def on_mouse_press(self, x, y, button, modifiers):
        """Handles mouse press events to trigger click handlers."""
        if button != pyglet.window.mouse.LEFT:
            return
        for pyglet_obj in reversed(self.created_elements):
            if hasattr(pyglet_obj, 'on_click') and pyglet_obj.on_click:
                if isinstance(pyglet_obj, pyglet.text.Label):
                    if pyglet_obj.color[3] == 0:
                        continue
                else:
                    if pyglet_obj.opacity == 0:
                        continue
                if self.is_point_inside(pyglet_obj, x, y):
                    pyglet_obj.on_click(pyglet_obj)
                    break

    def is_point_inside(self, pyglet_obj, px, py):
        """Determines if a point is inside a Pyglet object."""
        if isinstance(pyglet_obj, pyglet.shapes.Rectangle):
            return self._is_point_inside_rectangle(pyglet_obj, px, py)
        elif isinstance(pyglet_obj, pyglet.shapes.Circle):
            return self._is_point_inside_circle(pyglet_obj, px, py)
        elif isinstance(pyglet_obj, pyglet.shapes.Polygon):
            return self._is_point_inside_polygon(pyglet_obj, px, py)
        elif isinstance(pyglet_obj, pyglet.shapes.Line):
            return self._is_point_inside_line(pyglet_obj, px, py)
        elif isinstance(pyglet_obj, pyglet.text.Label):
            return self._is_point_inside_label(pyglet_obj, px, py)
        return False

    def _is_point_inside_rectangle(self, rect, px, py):
        """Hit test for rectangles considering rotation and anchor."""
        rotation = math.radians(rect.rotation)
        dx = px - rect.x
        dy = py - rect.y
        cos_theta = math.cos(-rotation)
        sin_theta = math.sin(-rotation)
        dx_local = dx * cos_theta - dy * sin_theta
        dy_local = dx * sin_theta + dy * cos_theta
        left = -rect.anchor_x
        right = rect.width - rect.anchor_x
        bottom = -rect.anchor_y
        top = rect.height - rect.anchor_y
        return left <= dx_local <= right and bottom <= dy_local <= top

    def _is_point_inside_circle(self, circle, px, py):
        """Hit test for circles."""
        dx = px - circle.x
        dy = py - circle.y
        distance = math.sqrt(dx**2 + dy**2)
        return distance <= circle.radius

    def _is_point_inside_polygon(self, polygon, px, py):
        """Hit test for polygons using ray casting."""
        vertices = polygon.vertices
        n = len(vertices) // 2
        inside = False
        p1x, p1y = vertices[0], vertices[1]
        for i in range(n + 1):
            p2x, p2y = vertices[2 * (i % n)], vertices[2 * (i % n) + 1]
            if py > min(p1y, p2y):
                if py <= max(p1y, p2y):
                    if px <= max(p1x, p2x):
                        if p1y != p2y:
                            xinters = (py - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
                        if p1x == p2x or px <= xinters:
                            inside = not inside
            p1x, p1y = p2x, p2y
        return inside

    def _is_point_inside_line(self, line, px, py):
        """Hit test for lines."""
        x1, y1 = line.x, line.y
        x2, y2 = line.x2, line.y2
        dx = x2 - x1
        dy = y2 - y1
        dpx = px - x1
        dpy = py - y1
        length_sq = dx**2 + dy**2
        if length_sq == 0:
            dist = math.sqrt(dpx**2 + dpy**2)
        else:
            t = (dpx * dx + dpy * dy) / length_sq
            if t < 0:
                dist = math.sqrt(dpx**2 + dpy**2)
            elif t > 1:
                dist = math.sqrt((px - x2)**2 + (py - y2)**2)
            else:
                proj_x = x1 + t * dx
                proj_y = y1 + t * dy
                dist = math.sqrt((px - proj_x)**2 + (py - proj_y)**2)
        return dist <= line.width / 2

    def _is_point_inside_label(self, label, px, py):
        """Hit test for text labels."""
        if label.anchor_x == 'left':
            left = label.x
            right = label.x + label.content_width
        elif label.anchor_x == 'center':
            left = label.x - label.content_width / 2
            right = label.x + label.content_width / 2
        elif label.anchor_x == 'right':
            left = label.x - label.content_width
            right = label.x
        else:
            left = label.x - label.anchor_x
            right = left + label.content_width
        if label.anchor_y == 'bottom':
            bottom = label.y
            top = label.y + label.content_height
        elif label.anchor_y == 'center':
            bottom = label.y - label.content_height / 2
            top = label.y + label.content_height / 2
        elif label.anchor_y == 'top':
            bottom = label.y - label.content_height
            top = label.y
        else:
            bottom = label.y - label.anchor_y
            top = bottom + label.content_height
        return left <= px <= right and bottom <= py <= top

interactive_scene = {
    "definitions": {
        "clickable_shape": {
            "type": "shape",
            "color": [255, 255, 255, 255],
            "opacity": 1.0
        }
    },
    "scene": [
        {
            "template": "clickable_shape",
            "name": "green_circle",
            "shape_type": "circle",
            "radius": 30,
            "position": [0, 0],
            "color": [0, 255, 0, 0],
            "on_click": "circle_click",
            "parent": "red_rect"  # Circle is a child of the rectangle
        },
        {
            "template": "clickable_shape",
            "name": "red_rect",
            "shape_type": "rectangle",
            "size": [100, 50],
            "position": [300, 400],
            "color": [255, 0, 0, 255],
            "on_click": "rect_click"
        },
        {
            "type": "text",
            "name": "click_label",
            "text": "Click Me!",
            "font_name": "Arial",
            "font_size": 16,
            "position": [700, 400],
            "color": [255, 255, 255, 255],
            "anchor_x": "center",
            "anchor_y": "center",
            "on_click": "label_click"
        }
    ]
}

# Click handler functions
def rect_click(element):
    """Change the rectangle's color to blue when clicked."""
    print(f"Clicked rectangle: {element._internal_name}")
    element.color = (0, 0, 255)  # Change to blue

def circle_click(element):
    """Double the circle's scale when clicked."""
    print(f"Clicked circle: {element._internal_name}")
    # Update the config to adjust scale
    config = loader._element_configs.get(element._internal_name)
    if config:
        config['scale'] = config.get('scale', 1.0) * 2
        loader._world_transforms.clear()  # Clear transforms to force recalculation
        loader._apply_world_transform(element._internal_name)

def label_click(element):
    """Update the label's text when clicked."""
    print(f"Clicked label: {element._internal_name}")
    element.text = "Clicked!"

# Demo execution
if __name__ == "__main__":
    WINDOW_WIDTH = 1280
    WINDOW_HEIGHT = 720
    window = pyglet.window.Window(
        WINDOW_WIDTH,
        WINDOW_HEIGHT,
        "Interactive Scene Demo"
    )
    main_batch = pyglet.graphics.Batch()

    # Define click handlers
    click_handlers = {
        "rect_click": rect_click,
        "circle_click": circle_click,
        "label_click": label_click
    }

    # Initialize SceneLoader
    loader = SceneLoader(batch=main_batch, window=window)
    loaded_objects = loader.load_scene_from_data(interactive_scene, click_handlers=click_handlers)
    print(f"Loaded {len(loaded_objects)} interactive objects.")

    @window.event
    def on_draw():
        window.clear()
        main_batch.draw()

    pyglet.app.run()
```