import gzip
import io
import json
import math
from bisect import bisect_left

import bpy
import numpy as np
from mathutils import Matrix, Quaternion, Vector

from .package_reader import PackageError


COMPONENTS = {
    "FLOAT32": (np.float32, 4),
    "UINT32": (np.uint32, 4),
    "UINT16": (np.uint16, 2),
}

MAX_ENVIRONMENT_PIXELS = 16 * 1024 * 1024
MAX_ENVIRONMENT_RGB = 65504.0


class SceneIRImporter:
    def __init__(self, context, package, pack_images=True):
        self.context = context
        self.package = package
        self.scene = package.scene
        self.pack_images = pack_images
        self.collection = None
        self.objects = {}
        self.meshes = {entry["id"]: entry for entry in self.scene["meshes"]}
        self.material_defs = {entry["id"]: entry for entry in self.scene["materials"]}
        self.texture_defs = {entry["id"]: entry for entry in self.scene["textures"]}
        self.camera_defs = {entry["id"]: entry for entry in self.scene["cameras"]}
        self.light_defs = {entry["id"]: entry for entry in self.scene["lights"]}
        self.node_defs = {entry["id"]: entry for entry in self.scene["nodes"]}
        self.materials = {}
        self.vertex_color_materials = {}
        self.images = {}
        self.buffers = {}
        self.armatures = {}
        self.armature_bones = {}
        self.armature_bind_scales = {}
        viewer = self.scene.get("viewer") or {}
        viewer_scene = viewer.get("scene") if isinstance(viewer, dict) else None
        profiles = viewer_scene.get("sssProfiles") if isinstance(viewer_scene, dict) else None
        self.sss_profiles = profiles if isinstance(profiles, list) else []
        self.has_subsurface = False
        self.has_refraction = False
        # Every datablock this import creates, so a failed import can remove
        # meshes/materials/images/actions instead of leaking them in bpy.data.
        self._created_datablocks = []
        self._previous_world = None
        self._world_created = False
        # Blender 5.x slotted actions: one channelbag per action, reused for
        # every fcurve this import adds to it.
        self._action_channelbags = {}

    def _track(self, collection, datablock):
        self._created_datablocks.append((collection, datablock))
        return datablock

    def import_scene(self):
        self._validate_accessors()
        self.collection = bpy.data.collections.new("3DRipper OSGJS Import")
        self.context.scene.collection.children.link(self.collection)
        try:
            self._create_materials()
            self._create_objects()
            self._link_hierarchy()
            self._create_skins()
            self._create_animations()
            self._apply_viewer_settings()
            return {"objects": len(self.objects), "materials": len(self.materials)}
        except Exception:
            self._remove_import_collection()
            raise

    def _validate_accessors(self):
        accessors = []
        for mesh in self.scene["meshes"]:
            for key in ("positions", "normals", "tangents", "colors", "joints", "weights", "indices"):
                if mesh.get(key):
                    accessors.append(mesh[key])
            accessors.extend(uv["values"] for uv in mesh.get("uvSets", []))
            for target in mesh.get("morphTargets", []):
                accessors.append(target["positions"])
                if target.get("normals"):
                    accessors.append(target["normals"])
        for skin in self.scene["skins"]:
            if skin.get("inverseBindMatrices"):
                accessors.append(skin["inverseBindMatrices"])
        for animation in self.scene["animations"]:
            for sampler in animation.get("samplers", []):
                accessors.extend((sampler["input"], sampler["output"]))
        for accessor in accessors:
            self._accessor_layout(accessor)

    def _accessor_layout(self, accessor):
        """Validate accessor bounds without decoding any payload."""
        try:
            dtype, component_size = COMPONENTS[accessor["componentType"]]
            item_size = int(accessor["itemSize"])
            count = int(accessor["count"])
            offset = int(accessor["byteOffset"])
            declared_length = int(accessor["byteLength"])
            buffer_path = accessor["buffer"]
        except (KeyError, TypeError, ValueError) as error:
            raise PackageError("Invalid binary accessor") from error
        if item_size < 1 or item_size > 16 or count < 0 or offset < 0:
            raise PackageError("Invalid binary accessor dimensions")
        expected_length = component_size * item_size * count
        if expected_length != declared_length:
            raise PackageError("Binary accessor byte length does not match its dimensions")
        data = self.buffers.get(buffer_path)
        if data is None:
            data = self.package.read_bytes(buffer_path)
            self.buffers[buffer_path] = data
        if offset + expected_length > len(data):
            raise PackageError("Binary accessor exceeds buffer bounds")
        return dtype, item_size, count, offset, data

    def _read_accessor(self, accessor):
        dtype, item_size, count, offset, data = self._accessor_layout(accessor)
        total = count * item_size
        if total == 0:
            return np.empty(0, dtype=dtype)
        # Zero-copy view into the shared buffer; self.buffers keeps it alive.
        return np.frombuffer(data, dtype=np.dtype(dtype).newbyteorder("<"), count=total, offset=offset)

    def _create_materials(self):
        for material_def in self.scene["materials"]:
            material = self._track(bpy.data.materials, bpy.data.materials.new(material_def.get("name") or material_def["id"]))
            material.use_nodes = True
            material["sketchfab_scene_ir_id"] = material_def["id"]
            material["sketchfab_shading_model"] = material_def.get("shadingModel", "principled")
            self._build_material_nodes(material, material_def)
            self._apply_render_state(material, material_def.get("renderState", {}))
            self.materials[material_def["id"]] = material

    def _material_for_mesh(self, material_id, has_vertex_colors):
        material = self.materials.get(material_id)
        if material is None or not has_vertex_colors:
            return material
        variant = self.vertex_color_materials.get(material_id)
        if variant is not None:
            return variant
        variant = self._track(bpy.data.materials, material.copy())
        variant.name = "{} [Vertex Color]".format(material.name)
        variant["sketchfab_vertex_color"] = True
        nodes = variant.node_tree.nodes
        links = variant.node_tree.links
        shader = next((node for node in nodes if node.bl_idname == "ShaderNodeBsdfPrincipled"), None)
        if shader is None:
            shader = next((node for node in nodes if node.bl_idname == "ShaderNodeEmission"), None)
        if shader is not None:
            color = nodes.new("ShaderNodeVertexColor")
            color.name = "3DRipper Vertex Color"
            color.label = "COLOR_0"
            color.layer_name = "Color"
            color.location = (-1420, 80)
            target = self._input(shader, ("Base Color", "Color"))
            if target:
                self._multiply_color_target_with_color(nodes, links, target, color.outputs["Color"], "Vertex Color")
            if variant.get("sketchfab_alpha_mode") != "OPAQUE":
                alpha_target = self._input(shader, ("Alpha",))
                if alpha_target is None:
                    unlit_alpha = nodes.get("3DRipper Unlit Alpha")
                    alpha_target = unlit_alpha.inputs[0] if unlit_alpha else None
                if alpha_target:
                    multiply = nodes.new("ShaderNodeMath")
                    multiply.name = "3DRipper Vertex Color Alpha Multiply"
                    multiply.operation = "MULTIPLY"
                    if alpha_target.is_linked:
                        previous = alpha_target.links[0]
                        source = previous.from_socket
                        links.remove(previous)
                        links.new(source, multiply.inputs[0])
                    else:
                        multiply.inputs[0].default_value = alpha_target.default_value
                    links.new(color.outputs["Alpha"], multiply.inputs[1])
                    links.new(multiply.outputs[0], alpha_target)
        self.vertex_color_materials[material_id] = variant
        return variant

    def _build_material_nodes(self, material, material_def):
        nodes = material.node_tree.nodes
        links = material.node_tree.links
        nodes.clear()
        output = nodes.new("ShaderNodeOutputMaterial")
        output.location = (760, 0)
        shader = nodes.new("ShaderNodeBsdfPrincipled")
        shader.location = (480, 0)
        links.new(shader.outputs["BSDF"], output.inputs["Surface"])
        props = material_def.get("properties", {})
        shading_model = material_def.get("shadingModel", "principled")
        is_spec_gloss = shading_model == "specularGlossiness"
        bindings = material_def.get("textureBindings", [])
        base_color = _rgba(
            props.get("specGlossDiffuseFactor") if is_spec_gloss else props.get("baseColor"),
            (1, 1, 1, 1),
        )
        glossiness = _number(props.get("specGlossGlossinessFactor"), 1)

        self._set_input(shader, ("Base Color",), base_color)
        self._set_input(shader, ("Metallic",), 0.0 if is_spec_gloss else float(props.get("metallic", 1)))
        self._set_input(
            shader,
            ("Roughness",),
            max(0.0, min(1.0, 1 - glossiness)) if is_spec_gloss else float(props.get("roughness", 1)),
        )
        self._set_input(shader, ("Alpha",), base_color[3])
        self._set_input(shader, ("Emission Color", "Emission"), _rgba(props.get("emissive"), (0, 0, 0, 1)))
        self._set_input(shader, ("Emission Strength",), float(props.get("emissiveStrength", 1)))
        self._set_input(shader, ("Coat Weight", "Clearcoat"), float(props.get("clearcoatFactor", 0)))
        self._set_input(shader, ("Coat Roughness", "Clearcoat Roughness"), float(props.get("clearcoatRoughnessFactor", 0)))
        self._set_input(shader, ("Transmission Weight", "Transmission"), float(props.get("transmissionFactor", 0)))
        self._set_input(shader, ("IOR",), float(props.get("ior", 1.5)))
        self._set_input(shader, ("Anisotropic", "Anisotropic IOR Level"), float(props.get("anisotropyStrength", 0)))
        self._set_input(shader, ("Anisotropic Rotation",), _anisotropy_turns(props.get("anisotropyRotation")))
        self._set_input(shader, ("Specular IOR Level",), 1.0 if is_spec_gloss else float(props.get("specularFactor", 1)))
        self._set_input(
            shader,
            ("Specular Tint",),
            _rgba(
                props.get("specGlossSpecularFactor") if is_spec_gloss else props.get("specularColorFactor"),
                (1, 1, 1, 1),
            ),
        )
        has_sheen = (
            props.get("sheenColorFactor") is not None
            or any(item.get("semantic") in ("sheenColor", "sheenRoughness") for item in bindings)
        )
        self._set_input(shader, ("Sheen Weight",), 1.0 if has_sheen else 0.0)
        self._set_input(shader, ("Sheen Tint",), _rgba(props.get("sheenColorFactor"), (1, 1, 1, 1)))
        extensions = material_def.get("extensions") or {}
        alpha_mode = material_def.get("renderState", {}).get("alphaMode", "OPAQUE")
        alpha_target = self._input(shader, ("Alpha",))

        if shading_model == "unlit":
            nodes.remove(shader)
            shader = nodes.new("ShaderNodeEmission")
            shader.location = (480, 0)
            self._set_input(shader, ("Color",), base_color)
            alpha_target = None
            if alpha_mode == "OPAQUE":
                links.new(shader.outputs["Emission"], output.inputs["Surface"])
            else:
                transparent = nodes.new("ShaderNodeBsdfTransparent")
                transparent.location = (260, -140)
                mix = nodes.new("ShaderNodeMixShader")
                mix.name = "3DRipper Unlit Alpha"
                mix.location = (620, 0)
                self._set_input(mix, ("Factor", "Fac"), base_color[3])
                links.new(transparent.outputs["BSDF"], mix.inputs[1])
                links.new(shader.outputs["Emission"], mix.inputs[2])
                links.new(mix.outputs[0], output.inputs["Surface"])
                alpha_target = mix.inputs[0]

        subsurface_bindings = {}

        for index, binding in enumerate(bindings):
            if binding.get("semantic") == "matcap":
                continue
            opacity_info = extensions.get("SKETCHFAB_opacityTextureInfo", {})
            if binding.get("semantic") == "opacity" and opacity_info.get("source") == "AlphaMask":
                binding = dict(binding)
                binding["channel"] = "a"
            image_node = self._texture_node(nodes, binding, index)
            if image_node is None:
                continue
            self._connect_uv(nodes, links, image_node, binding, index)
            if binding.get("semantic") in ("subsurface", "subsurfaceTranslucency"):
                subsurface_bindings[binding["semantic"]] = (image_node, binding)
                continue
            self._connect_texture(
                nodes,
                links,
                shader,
                image_node,
                binding,
                props,
                alpha_mode,
                shading_model,
                material,
                alpha_target,
            )
        if shading_model == "matcap":
            matcap = next((item for item in bindings if item.get("semantic") == "matcap"), None)
            if matcap:
                self._connect_matcap(nodes, links, shader, matcap, props)
        self._apply_subsurface(
            nodes,
            links,
            shader,
            material,
            props.get("subsurface") or extensions.get("SKETCHFAB_subsurface"),
            subsurface_bindings,
        )
        self._apply_refraction(
            shader,
            material,
            props.get("refraction") or extensions.get("SKETCHFAB_refraction"),
        )

    def _texture_node(self, nodes, binding, index):
        texture_def = self.texture_defs.get(binding.get("textureId"))
        if not texture_def:
            return None
        color_space = binding.get("colorSpace", "sRGB")
        image = self._load_image(texture_def, color_space)
        node = nodes.new("ShaderNodeTexImage")
        node.name = "{}_{}".format(binding.get("semantic", "texture"), index)
        node.label = "{} / UV{}".format(binding.get("semantic", "texture"), binding.get("uvSet", 0))
        node.image = image
        node.location = (-720, 260 - index * 180)
        return node

    def _load_image(self, texture_def, color_space="sRGB"):
        texture_id = texture_def["id"]
        image_key = (texture_id, color_space)
        if image_key in self.images:
            return self.images[image_key]
        path = self.package.materialize_resource(texture_def["resourcePath"])
        image = self._track(bpy.data.images, bpy.data.images.load(str(path), check_existing=False))
        base_name = texture_def.get("name") or texture_id
        image.name = base_name if color_space == "sRGB" else "{} [Non-Color]".format(base_name)
        image.colorspace_settings.name = color_space
        image["sketchfab_scene_ir_id"] = texture_id
        image["sketchfab_color_space"] = color_space
        if self.pack_images:
            image.pack()
        self.images[image_key] = image
        return image

    def _connect_uv(self, nodes, links, image_node, binding, index):
        uv = nodes.new("ShaderNodeUVMap")
        uv.uv_map = "UVMap" if int(binding.get("uvSet", 0)) == 0 else "UVMap_{}".format(binding["uvSet"])
        uv.location = (-1120, 260 - index * 180)
        vector_output = uv.outputs["UV"]
        transform = binding.get("transform") or {}
        if transform:
            mapping = nodes.new("ShaderNodeMapping")
            mapping.vector_type = "POINT"
            mapping.location = (-920, 260 - index * 180)
            offset = transform.get("offset", (0, 0))
            scale = transform.get("scale", (1, 1))
            mapping.inputs["Location"].default_value = (float(offset[0]), float(offset[1]), 0)
            mapping.inputs["Scale"].default_value = (float(scale[0]), float(scale[1]), 1)
            mapping.inputs["Rotation"].default_value[2] = float(transform.get("rotation", 0))
            links.new(vector_output, mapping.inputs["Vector"])
            vector_output = mapping.outputs["Vector"]
        links.new(vector_output, image_node.inputs["Vector"])

    def _connect_texture(
        self,
        nodes,
        links,
        shader,
        image_node,
        binding,
        props,
        alpha_mode="OPAQUE",
        shading_model="principled",
        material=None,
        alpha_target=None,
    ):
        semantic = binding.get("semantic")
        color = image_node.outputs["Color"]
        alpha = image_node.outputs["Alpha"]
        if semantic == "normal" or semantic == "clearcoatNormal":
            normal = nodes.new("ShaderNodeNormalMap")
            normal.inputs["Strength"].default_value = float(props.get(
                "normalScale" if semantic == "normal" else "clearcoatNormalScale", 1
            ))
            links.new(color, normal.inputs["Color"])
            target = self._input(shader, ("Normal",) if semantic == "normal" else ("Coat Normal", "Clearcoat Normal"))
            if target:
                links.new(normal.outputs["Normal"], target)
            return

        if semantic == "baseColor" or semantic == "specGlossDiffuse":
            factor = _rgba(
                props.get("specGlossDiffuseFactor") if semantic == "specGlossDiffuse" else props.get("baseColor"),
                (1, 1, 1, 1),
            )
            source = self._color_with_factor(nodes, links, color, factor, "Base Color")
            target = self._input(shader, ("Base Color", "Color"))
            if target:
                links.new(source, target)
            target_alpha = alpha_target if alpha_target is not None else self._input(shader, ("Alpha",))
            if target_alpha and alpha_mode != "OPAQUE":
                alpha_source = self._scalar_with_factor(nodes, links, alpha, factor[3], "Alpha")
                links.new(alpha_source, target_alpha)
            return
        if semantic == "emissive":
            source = self._color_with_factor(
                nodes,
                links,
                color,
                _rgba(props.get("emissive"), (1, 1, 1, 1)),
                "Emission",
            )
            target = self._input(shader, ("Emission Color", "Emission", "Color"))
            if target:
                links.new(source, target)
            return
        if semantic == "opacity":
            target = alpha_target if alpha_target is not None else self._input(shader, ("Alpha",))
            if target:
                source = self._binding_scalar(nodes, links, color, alpha, binding)
                source = self._scalar_with_factor(
                    nodes,
                    links,
                    source,
                    self._opacity_factor(props, shading_model),
                    "Opacity",
                )
                links.new(source, target)
            return

        if semantic == "occlusion":
            target = self._input(shader, ("Base Color",))
            if target and shader.bl_idname == "ShaderNodeBsdfPrincipled":
                source = self._binding_scalar(nodes, links, color, alpha, binding)
                source = self._occlusion_with_strength(
                    nodes,
                    links,
                    source,
                    _number(props.get("occlusionStrength"), 1),
                )
                self._multiply_color_target(nodes, links, target, source, "Occlusion")
                if material is not None:
                    material["sketchfab_occlusion_mode"] = "base-color-multiply"
            return

        if semantic == "sheenColor":
            target = self._input(shader, ("Sheen Tint",))
            if target:
                source = self._color_with_factor(
                    nodes,
                    links,
                    color,
                    _rgba(props.get("sheenColorFactor"), (1, 1, 1, 1)),
                    "Sheen Color",
                )
                links.new(source, target)
            return

        if semantic == "specularColor":
            target = self._input(shader, ("Specular Tint",))
            if target:
                source = self._color_with_factor(
                    nodes,
                    links,
                    color,
                    _rgba(props.get("specularColorFactor"), (1, 1, 1, 1)),
                    "Specular Color",
                )
                links.new(source, target)
            return

        if semantic == "anisotropy":
            separate = nodes.new("ShaderNodeSeparateColor")
            separate.name = "3DRipper Anisotropy Channels"
            links.new(color, separate.inputs["Color"])
            strength = self._scalar_with_factor(
                nodes,
                links,
                separate.outputs["Red"],
                _number(props.get("anisotropyStrength"), 0),
                "Anisotropy Strength",
            )
            strength_target = self._input(shader, ("Anisotropic", "Anisotropic IOR Level"))
            if strength_target:
                links.new(strength, strength_target)
            rotation = separate.outputs["Green"]
            rotation_offset = _anisotropy_turns(props.get("anisotropyRotation"))
            if abs(rotation_offset) > 1e-6:
                add = nodes.new("ShaderNodeMath")
                add.name = "3DRipper Anisotropy Rotation"
                add.operation = "ADD"
                add.inputs[1].default_value = rotation_offset
                links.new(rotation, add.inputs[0])
                rotation = add.outputs[0]
            rotation_target = self._input(shader, ("Anisotropic Rotation",))
            if rotation_target:
                links.new(rotation, rotation_target)
            return

        if semantic == "specGlossSpecularGlossiness":
            target = self._input(shader, ("Specular Tint",))
            if target:
                source = self._color_with_factor(
                    nodes,
                    links,
                    color,
                    _rgba(props.get("specGlossSpecularFactor"), (1, 1, 1, 1)),
                    "SpecGloss Specular",
                )
                links.new(source, target)
            self._connect_glossiness(
                nodes,
                links,
                shader,
                alpha,
                _number(props.get("specGlossGlossinessFactor"), 1),
                "SpecGloss Glossiness",
            )
            return

        if semantic == "specGlossSpecular":
            target = self._input(shader, ("Specular Tint",))
            if target:
                source = self._color_with_factor(
                    nodes,
                    links,
                    color,
                    _rgba(props.get("specGlossSpecularFactor"), (1, 1, 1, 1)),
                    "SpecGloss Specular",
                )
                links.new(source, target)
            return

        if semantic == "specGlossGlossiness":
            source = self._binding_scalar(nodes, links, color, alpha, binding)
            self._connect_glossiness(
                nodes,
                links,
                shader,
                source,
                _number(props.get("specGlossGlossinessFactor"), 1),
                "SpecGloss Glossiness",
            )
            return

        scalar_targets = {
            "metallic": ("Metallic",),
            "roughness": ("Roughness",),
            "occlusion": (),
            "clearcoat": ("Coat Weight", "Clearcoat"),
            "clearcoatRoughness": ("Coat Roughness", "Clearcoat Roughness"),
            "sheenRoughness": ("Sheen Roughness",),
            "transmission": ("Transmission Weight", "Transmission"),
            "specular": ("Specular IOR Level",),
        }
        if semantic == "metallicRoughness":
            separate = nodes.new("ShaderNodeSeparateColor")
            separate.name = "3DRipper Metallic Roughness Channels"
            links.new(color, separate.inputs["Color"])
            metallic = self._input(shader, ("Metallic",))
            roughness = self._input(shader, ("Roughness",))
            if metallic:
                source = self._scalar_with_factor(
                    nodes,
                    links,
                    separate.outputs["Blue"],
                    _number(props.get("metallic"), 1),
                    "Metallic",
                )
                links.new(source, metallic)
            if roughness:
                source = self._scalar_with_factor(
                    nodes,
                    links,
                    separate.outputs["Green"],
                    _number(props.get("roughness"), 1),
                    "Roughness",
                )
                links.new(source, roughness)
            return
        targets = scalar_targets.get(semantic)
        if targets:
            target = self._input(shader, targets)
            if target:
                source = self._binding_scalar(nodes, links, color, alpha, binding)
                factor = {
                    "metallic": _number(props.get("metallic"), 1),
                    "roughness": _number(props.get("roughness"), 1),
                    "clearcoat": _number(props.get("clearcoatFactor"), 0),
                    "clearcoatRoughness": _number(props.get("clearcoatRoughnessFactor"), 0),
                    "sheenRoughness": _number(props.get("sheenRoughnessFactor"), 0),
                    "transmission": _number(props.get("transmissionFactor"), 0),
                    "specular": _number(props.get("specularFactor"), 1),
                }.get(semantic, 1)
                source = self._scalar_with_factor(nodes, links, source, factor, semantic.title())
                links.new(source, target)

    def _binding_scalar(self, nodes, links, color, alpha, binding):
        return alpha if binding.get("channel") == "a" \
            else self._channel_output(nodes, links, color, binding.get("channel", "r"))

    def _color_with_factor(self, nodes, links, color, factor, label):
        factor = _rgba(factor, (1, 1, 1, 1))
        if all(abs(component - 1) < 1e-6 for component in factor[:3]):
            return color
        multiply = nodes.new("ShaderNodeMixRGB")
        multiply.name = "3DRipper {} Factor".format(label)
        multiply.label = "{} x factor".format(label)
        multiply.blend_type = "MULTIPLY"
        self._set_input(multiply, ("Factor", "Fac"), 1.0)
        multiply.inputs["Color1"].default_value = factor
        links.new(color, multiply.inputs["Color2"])
        return multiply.outputs["Color"]

    def _scalar_with_factor(self, nodes, links, source, factor, label):
        if abs(factor - 1) < 1e-6:
            return source
        multiply = nodes.new("ShaderNodeMath")
        multiply.name = "3DRipper {} Factor".format(label)
        multiply.label = "{} x factor".format(label)
        multiply.operation = "MULTIPLY"
        multiply.inputs[1].default_value = factor
        links.new(source, multiply.inputs[0])
        return multiply.outputs[0]

    def _occlusion_with_strength(self, nodes, links, source, strength):
        strength = min(max(strength, 0), 1)
        if abs(strength - 1) < 1e-6:
            return source
        mix = nodes.new("ShaderNodeMath")
        mix.name = "3DRipper Occlusion Strength"
        mix.operation = "MULTIPLY_ADD"
        mix.inputs[1].default_value = strength
        mix.inputs[2].default_value = 1 - strength
        links.new(source, mix.inputs[0])
        return mix.outputs[0]

    def _multiply_color_target(self, nodes, links, target, factor, label):
        combine = nodes.new("ShaderNodeCombineColor")
        combine.name = "3DRipper {} Color".format(label)
        for channel in ("Red", "Green", "Blue"):
            links.new(factor, combine.inputs[channel])
        self._multiply_color_target_with_color(nodes, links, target, combine.outputs["Color"], label)

    def _multiply_color_target_with_color(self, nodes, links, target, color, label):
        multiply = nodes.new("ShaderNodeMixRGB")
        multiply.name = "3DRipper {} Multiply".format(label)
        multiply.label = "{} x base color".format(label)
        multiply.blend_type = "MULTIPLY"
        self._set_input(multiply, ("Factor", "Fac"), 1.0)
        if target.is_linked:
            previous = target.links[0]
            source = previous.from_socket
            links.remove(previous)
            links.new(source, multiply.inputs["Color1"])
        else:
            multiply.inputs["Color1"].default_value = target.default_value
        links.new(color, multiply.inputs["Color2"])
        links.new(multiply.outputs["Color"], target)

    def _connect_glossiness(self, nodes, links, shader, source, factor, label):
        glossiness = self._scalar_with_factor(nodes, links, source, factor, label)
        invert = nodes.new("ShaderNodeMath")
        invert.name = "3DRipper {} To Roughness".format(label)
        invert.operation = "SUBTRACT"
        invert.inputs[0].default_value = 1.0
        links.new(glossiness, invert.inputs[1])
        target = self._input(shader, ("Roughness",))
        if target:
            links.new(invert.outputs[0], target)

    @staticmethod
    def _opacity_factor(props, shading_model):
        key = "specGlossDiffuseFactor" if shading_model == "specularGlossiness" else "baseColor"
        return _rgba(props.get(key), (1, 1, 1, 1))[3]

    def _apply_subsurface(self, nodes, links, shader, material, settings, bindings):
        if shader.bl_idname != "ShaderNodeBsdfPrincipled" or not isinstance(settings, dict):
            return
        scattering = settings.get("scattering") or {}
        translucency = settings.get("translucency") or {}
        if not isinstance(scattering, dict) or not isinstance(translucency, dict):
            return
        scattering_factor = _number(scattering.get("factor"), 0)
        translucency_factor = _number(translucency.get("factor"), scattering_factor)
        weight = min(max(translucency_factor, 0), 1)
        profile_index = int(_number(scattering.get("profile"), -1))
        profile = self.sss_profiles[profile_index] \
            if 0 <= profile_index < len(self.sss_profiles) and isinstance(self.sss_profiles[profile_index], dict) \
            else {}
        thickness = max(_number(translucency.get("thicknessFactor"), scattering_factor), 0.001)
        falloff = _vec3(profile.get("falloff"), (1, 0.2, 0.1))
        radius = tuple(max(component * thickness, 0.001) for component in falloff)
        self._set_input(shader, ("Subsurface Weight",), weight)
        self._set_input(shader, ("Subsurface Radius",), radius)
        self._set_input(shader, ("Subsurface Scale",), 1.0)

        mask = None
        for semantic in ("subsurface", "subsurfaceTranslucency"):
            entry = bindings.get(semantic)
            if not entry:
                continue
            image_node, binding = entry
            source = self._channel_output(nodes, links, image_node.outputs["Color"], binding.get("channel", "r"))
            if mask is None:
                mask = source
            else:
                multiply = nodes.new("ShaderNodeMath")
                multiply.name = "3DRipper Subsurface Texture Mask"
                multiply.operation = "MULTIPLY"
                links.new(mask, multiply.inputs[0])
                links.new(source, multiply.inputs[1])
                mask = multiply.outputs[0]
        if mask is not None:
            multiplier = nodes.new("ShaderNodeMath")
            multiplier.name = "3DRipper Subsurface Weight"
            multiplier.operation = "MULTIPLY"
            multiplier.inputs[1].default_value = weight
            links.new(mask, multiplier.inputs[0])
            target = self._input(shader, ("Subsurface Weight",))
            if target:
                links.new(multiplier.outputs[0], target)

        material["sketchfab_subsurface_mode"] = "principled-native"
        material["sketchfab_subsurface_profile"] = profile_index
        material["sketchfab_subsurface_radius"] = list(radius)
        material["sketchfab_subsurface_translucency_color"] = list(
            _vec3(translucency.get("color"), (1, 1, 1))
        )
        if isinstance(profile, dict) and profile.get("strength") is not None:
            material["sketchfab_subsurface_profile_strength"] = list(
                _vec3(profile.get("strength"), (1, 1, 1))
            )
        self.has_subsurface = True

    def _apply_refraction(self, shader, material, settings):
        if shader.bl_idname != "ShaderNodeBsdfPrincipled" or not isinstance(settings, dict):
            return
        self._set_input(shader, ("Thin Wall",), bool(settings.get("thinLayer", False)))
        material["sketchfab_refraction_mode"] = "principled-transmission"
        material["sketchfab_refraction_thin_layer"] = bool(settings.get("thinLayer", False))
        material["sketchfab_refraction_color"] = list(_vec3(settings.get("color"), (1, 1, 1)))
        material["sketchfab_refraction_use_albedo_tint"] = bool(settings.get("useAlbedoTint", False))
        self.has_refraction = True

    def _channel_output(self, nodes, links, color, channel):
        names = {"r": "Red", "g": "Green", "b": "Blue"}
        if channel not in names:
            return color
        separate = nodes.new("ShaderNodeSeparateColor")
        links.new(color, separate.inputs["Color"])
        return separate.outputs[names[channel]]

    def _connect_matcap(self, nodes, links, shader, binding, props):
        image_node = self._texture_node(nodes, binding, 0)
        if not image_node:
            return
        geometry = nodes.new("ShaderNodeNewGeometry")
        transform = nodes.new("ShaderNodeVectorTransform")
        transform.vector_type = "NORMAL"
        transform.convert_from = "WORLD"
        transform.convert_to = "CAMERA"
        scale = nodes.new("ShaderNodeVectorMath")
        scale.operation = "SCALE"
        scale.inputs[3].default_value = 0.5
        add = nodes.new("ShaderNodeVectorMath")
        add.operation = "ADD"
        add.inputs[1].default_value = (0.5, 0.5, 0)
        links.new(geometry.outputs["Normal"], transform.inputs["Vector"])
        links.new(transform.outputs["Vector"], scale.inputs[0])
        links.new(scale.outputs["Vector"], add.inputs[0])
        links.new(add.outputs["Vector"], image_node.inputs["Vector"])
        target = self._input(shader, ("Base Color", "Color"))
        if target:
            matcap = props.get("matcap") if isinstance(props.get("matcap"), dict) else {}
            color = _rgba(matcap.get("color"), (1, 1, 1, 1))
            factor = _number(matcap.get("factor"), 1)
            color = tuple(component * factor for component in color[:3]) + (color[3],)
            source = self._color_with_factor(nodes, links, image_node.outputs["Color"], color, "Matcap")
            self._multiply_color_target_with_color(nodes, links, target, source, "Matcap")

    def _apply_render_state(self, material, state):
        material.use_backface_culling = not bool(state.get("doubleSided", False))
        alpha_mode = state.get("alphaMode", "OPAQUE")
        if hasattr(material, "surface_render_method"):
            material.surface_render_method = "BLENDED" if alpha_mode == "BLEND" else "DITHERED"
            if hasattr(material, "alpha_threshold"):
                material.alpha_threshold = float(state.get("alphaCutoff", 0.5))
        elif hasattr(material, "blend_method"):
            material.blend_method = "CLIP" if alpha_mode == "MASK" else "BLEND" if alpha_mode == "BLEND" else "OPAQUE"
            material.alpha_threshold = float(state.get("alphaCutoff", 0.5))
        material["sketchfab_alpha_mode"] = alpha_mode
        material["sketchfab_alpha_cutoff"] = float(state.get("alphaCutoff", 0.5))
        if state.get("role"):
            material["sketchfab_role"] = state["role"]
            if state["role"] == "outline":
                material.use_backface_culling = True
                material["sketchfab_outline_mode"] = "source-inverted-hull"

    def _create_objects(self):
        for node_def in self.scene["nodes"]:
            data = None
            if node_def.get("meshId"):
                data = self._create_mesh(node_def)
            elif node_def.get("cameraId"):
                data = self._create_camera(self.camera_defs[node_def["cameraId"]])
            elif node_def.get("lightId"):
                data = self._create_light(self.light_defs[node_def["lightId"]])
            obj = bpy.data.objects.new(node_def.get("name") or node_def["id"], data)
            obj["sketchfab_scene_ir_id"] = node_def["id"]
            self._apply_transform(obj, node_def.get("localTransform", {}))
            self.collection.objects.link(obj)
            self.objects[node_def["id"]] = obj
            if node_def.get("meshId"):
                self._create_morph_targets(obj, self.meshes[node_def["meshId"]])
        if self.scene.get("initialCamera") and not self.scene["cameras"]:
            self._create_initial_camera()

    def _create_mesh(self, node_def):
        mesh_def = self.meshes[node_def["meshId"]]
        positions = np.asarray(self._read_accessor(mesh_def["positions"]), dtype=np.float32)
        indices = np.asarray(self._read_accessor(mesh_def["indices"]), dtype=np.int32)
        vertex_count = len(positions) // 3
        loop_count = len(indices) - len(indices) % 3
        face_count = loop_count // 3
        mesh = self._track(bpy.data.meshes, bpy.data.meshes.new(mesh_def.get("name") or mesh_def["id"]))
        mesh.vertices.add(vertex_count)
        mesh.vertices.foreach_set("co", positions[:vertex_count * 3])
        mesh.loops.add(loop_count)
        mesh.loops.foreach_set("vertex_index", indices[:loop_count])
        mesh.polygons.add(face_count)
        mesh.polygons.foreach_set("loop_start", np.arange(0, loop_count, 3, dtype=np.int32))
        mesh.polygons.foreach_set("loop_total", np.full(face_count, 3, dtype=np.int32))
        mesh.update(calc_edges=True)
        # Repair out-of-range indices and degenerate faces from malformed
        # packages before any per-loop attribute layers are filled in.
        mesh.validate(verbose=False)
        for uv_def in mesh_def.get("uvSets", []):
            values = np.asarray(self._read_accessor(uv_def["values"]), dtype=np.float32).reshape(-1, 2)
            layer = mesh.uv_layers.new(name=uv_def.get("name") or "UVMap")
            loop_vertices = np.empty(len(mesh.loops), dtype=np.int32)
            mesh.loops.foreach_get("vertex_index", loop_vertices)
            layer.data.foreach_set("uv", values[loop_vertices].ravel())
        has_vertex_colors = bool(mesh_def.get("colors"))
        if has_vertex_colors:
            values = np.asarray(self._read_accessor(mesh_def["colors"]), dtype=np.float32)
            color = mesh.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="POINT")
            expected = len(color.data) * 4
            if len(values) < expected:
                raise PackageError("Vertex color data is shorter than the mesh: {}".format(mesh.name))
            color.data.foreach_set("color", values[:expected])
        for material_id in node_def.get("materialIds", []):
            material = self._material_for_mesh(material_id, has_vertex_colors)
            if material:
                mesh.materials.append(material)
        mesh["sketchfab_scene_ir_id"] = mesh_def["id"]
        return mesh

    def _create_camera(self, camera_def):
        camera = self._track(bpy.data.cameras, bpy.data.cameras.new(camera_def.get("name") or camera_def["id"]))
        camera.type = "ORTHO" if camera_def.get("type") == "orthographic" else "PERSP"
        camera.sensor_fit = "VERTICAL"
        camera.clip_start = max(float(camera_def.get("near", 0.01)), 0.0001)
        camera.clip_end = max(float(camera_def.get("far", 1000)), camera.clip_start + 0.1)
        if camera.type == "PERSP" and camera_def.get("fov") is not None:
            fov = float(camera_def["fov"])
            camera.angle = math.radians(fov) if fov > math.pi else fov
        elif camera.type == "ORTHO":
            camera.ortho_scale = max(float(camera_def.get("ymag", camera_def.get("xmag", 1))) * 2, 0.001)
        return camera

    def _create_light(self, light_def):
        light_type = {"directional": "SUN", "point": "POINT", "spot": "SPOT"}.get(light_def.get("type"), "POINT")
        light = self._track(bpy.data.lights, bpy.data.lights.new(light_def.get("name") or light_def["id"], light_type))
        light.color = tuple(light_def.get("color", (1, 1, 1)))[:3]
        light.energy = float(light_def.get("intensity", 1))
        if light_def.get("range") is not None and hasattr(light, "cutoff_distance"):
            light.use_custom_distance = True
            light.cutoff_distance = float(light_def["range"])
        if light_type == "SPOT":
            light.spot_size = float(light_def.get("outerConeAngle", math.pi / 4)) * 2
            outer = max(float(light_def.get("outerConeAngle", math.pi / 4)), 1e-6)
            inner = float(light_def.get("innerConeAngle", 0))
            light.spot_blend = max(0, min(1, 1 - inner / outer))
        return light

    def _create_morph_targets(self, obj, mesh_def):
        targets = mesh_def.get("morphTargets") or []
        if not targets:
            return
        obj.shape_key_add(name="Basis", from_mix=False)
        for target in targets:
            values = np.asarray(self._read_accessor(target["positions"]), dtype=np.float32)
            shape = obj.shape_key_add(name=target.get("name") or "Shape", from_mix=False)
            expected = len(shape.data) * 3
            if len(values) < expected:
                raise PackageError("Morph target data is shorter than the mesh: {}".format(shape.name))
            shape.data.foreach_set("co", values[:expected])
            shape.value = float(target.get("weight", 0))

    def _link_hierarchy(self):
        for node_def in self.scene["nodes"]:
            parent_id = node_def.get("parentId")
            if parent_id and parent_id in self.objects:
                self.objects[node_def["id"]].parent = self.objects[parent_id]

    def _create_skins(self):
        if not self.scene["skins"]:
            return
        skinned_nodes = {}
        for node_def in self.scene["nodes"]:
            if node_def.get("skinId") and node_def.get("meshId") in self.meshes:
                skinned_nodes.setdefault(node_def["skinId"], []).append(node_def)
        # One depsgraph evaluation for the matrix_world reads below; the
        # per-skin mode_set operators keep it current afterwards.
        self.context.view_layer.update()
        for skin in self.scene["skins"]:
            armature_data = self._track(bpy.data.armatures, bpy.data.armatures.new(skin.get("name") or skin["id"]))
            armature_obj = bpy.data.objects.new(skin.get("name") or skin["id"], armature_data)
            self.collection.objects.link(armature_obj)
            self.armatures[skin["id"]] = armature_obj
            inverse_bind_values = self._read_accessor(skin["inverseBindMatrices"]) \
                if skin.get("inverseBindMatrices") else []
            skin_nodes = skinned_nodes.get(skin["id"], [])
            geometry_node = skin_nodes[0] if skin_nodes else None
            geometry_obj = self.objects.get(geometry_node["id"]) if geometry_node else None
            skeleton_obj = self.objects.get(skin.get("skeletonId"))
            geometry_world = geometry_obj.matrix_world.copy() if geometry_obj else Matrix.Identity(4)
            skeleton_world = skeleton_obj.matrix_world.copy() if skeleton_obj else geometry_world
            joints = skin.get("joints", [])
            inverse_binds = []
            for joint_index in range(len(joints)):
                offset = joint_index * 16
                if offset + 16 > len(inverse_bind_values):
                    break
                inverse_binds.append(Matrix([
                    inverse_bind_values[offset + row:offset + row + 4]
                    for row in range(0, 16, 4)
                ]).transposed())
            bind_scale = 1.0
            for inverse_bind in inverse_binds:
                candidate_scale = abs(inverse_bind.to_3x3().determinant()) ** (1 / 3)
                if candidate_scale > 1e-8 and abs(candidate_scale - 1) > 1e-6:
                    bind_scale = candidate_scale
                    break
            self.armature_bind_scales[skin["id"]] = bind_scale
            bind_scale_matrix = Matrix.Scale(bind_scale, 4)
            # OSGJS inverse-bind matrices can carry a shared unit scale (for
            # example 0.01 for centimeter geometry). Blender edit bones cannot
            # retain scale in their rest matrices, so move that common scale to
            # the armature object and normalize every bind matrix on the right.
            armature_world = skeleton_world @ bind_scale_matrix
            armature_obj.matrix_world = armature_world
            bone_matrices = {
                joint_id: inverse_binds[joint_index].inverted_safe() @ bind_scale_matrix
                for joint_index, joint_id in enumerate(joints[:len(inverse_binds)])
            }
            self.context.view_layer.objects.active = armature_obj
            armature_obj.select_set(True)
            bpy.ops.object.mode_set(mode="EDIT")
            bones = {}
            for joint_id in skin.get("joints", []):
                node_def = self.node_defs.get(joint_id, {})
                bone = armature_data.edit_bones.new(node_def.get("name") or joint_id)
                matrix = bone_matrices.get(joint_id)
                if matrix is not None:
                    bone.head = (0, 0, 0)
                    bone.tail = (0, 0.1, 0)
                    bone.matrix = matrix
                else:
                    translation = node_def.get("localTransform", {}).get("translation", (0, 0, 0))
                    bone.head = Vector(tuple(translation)[:3])
                    bone.tail = bone.head + Vector((0, 0.1, 0))
                child_lengths = [
                    (bone_matrices[child_id].translation - matrix.translation).length
                    for child_id in node_def.get("childIds", [])
                    if matrix is not None and child_id in bone_matrices
                ]
                bone.length = max(next((value for value in child_lengths if value > 1e-5), 0.1), 1e-4)
                bones[joint_id] = bone
            for joint_id, bone in bones.items():
                parent_id = self.node_defs.get(joint_id, {}).get("parentId")
                if parent_id in bones:
                    bone.parent = bones[parent_id]
            # EditBone RNA references become invalid after leaving Edit mode.
            # Keep plain strings for the later vertex-group pass.
            bone_names = {joint_id: str(bone.name) for joint_id, bone in bones.items()}
            bpy.ops.object.mode_set(mode="OBJECT")
            armature_obj.select_set(False)
            self.armature_bones[skin["id"]] = bone_names
            for node_def in skin_nodes:
                obj = self.objects[node_def["id"]]
                mesh_def = self.meshes[node_def["meshId"]]
                modifier = obj.modifiers.new(name="Armature", type="ARMATURE")
                modifier.object = armature_obj
                joint_values = self._read_accessor(mesh_def["joints"]) if mesh_def.get("joints") else []
                weight_values = self._read_accessor(mesh_def["weights"]) if mesh_def.get("weights") else []
                # Preserve the skin joint-table indexes even when a malformed
                # package references a joint that could not be created.
                groups = [
                    obj.vertex_groups.new(name=bone_names[joint]) if joint in bone_names else None
                    for joint in joints
                ]
                influence_count = min(
                    len(joint_values), len(weight_values), len(obj.data.vertices) * 4
                )
                if influence_count and groups:
                    joint_indices = np.asarray(joint_values[:influence_count], dtype=np.int64)
                    weight_array = np.asarray(weight_values[:influence_count], dtype=np.float32)
                    vertex_indices = np.arange(influence_count, dtype=np.int64) // 4
                    valid = (weight_array > 0) & (joint_indices < len(groups))
                    # Bucket by (joint, weight value) so vertex_groups.add runs
                    # once per bucket instead of once per influence.
                    for joint_index in np.unique(joint_indices[valid]):
                        group = groups[joint_index]
                        if group is None:
                            continue
                        selection = valid & (joint_indices == joint_index)
                        joint_weights = weight_array[selection]
                        joint_vertices = vertex_indices[selection]
                        for weight_value in np.unique(joint_weights):
                            group.add(
                                joint_vertices[joint_weights == weight_value].tolist(),
                                float(weight_value),
                                "REPLACE",
                            )
                obj.parent = armature_obj
                obj.matrix_world = armature_world

    def _create_animations(self):
        fps = self.context.scene.render.fps / max(self.context.scene.render.fps_base, 1e-6)
        joint_ids = {
            joint_id
            for bone_names in self.armature_bones.values()
            for joint_id in bone_names
        }
        last_frame = float(self.context.scene.frame_end)
        for animation in self.scene["animations"]:
            samplers = [self._animation_sampler(item) for item in animation.get("samplers", [])]
            last_frame = max(last_frame, self._create_armature_actions(animation, samplers, fps))
            grouped = {}
            for channel in animation.get("channels", []):
                if channel.get("targetNode") in joint_ids and channel.get("targetPath") in {
                    "translation", "rotation", "scale"
                }:
                    continue
                grouped.setdefault(channel.get("targetNode"), []).append(channel)
            for node_id, channels in grouped.items():
                obj = self.objects.get(node_id)
                if not obj:
                    continue
                action = self._track(
                    bpy.data.actions,
                    bpy.data.actions.new("{} / {}".format(animation.get("name", "Animation"), obj.name)),
                )
                obj.animation_data_create()
                obj.animation_data.action = action
                group_name = animation.get("name") or "Animation"
                shape_key_action = None
                for channel in channels:
                    sampler_def = animation["samplers"][int(channel["sampler"])]
                    sampler = samplers[int(channel["sampler"])]
                    times = np.asarray(sampler["times"], dtype=np.float64)
                    width = sampler["width"]
                    if not len(times) or width < 1:
                        continue
                    values = np.asarray(sampler["values"], dtype=np.float64)
                    values = values[:len(values) - len(values) % width].reshape(-1, width)
                    if sampler_def.get("interpolation") == "CUBICSPLINE":
                        values = values[:len(values) - len(values) % 3].reshape(-1, 3, width)[:, 1, :]
                    frames = times * fps
                    count = min(len(frames), len(values))
                    if not count:
                        continue
                    last_frame = max(last_frame, float(frames[count - 1]))
                    path = channel.get("targetPath")
                    if path == "translation":
                        self._action_fcurves(action, obj, "location", 3, frames, values, group_name)
                    elif path == "rotation":
                        obj.rotation_mode = "QUATERNION"
                        quaternions = np.column_stack(
                            (values[:, 3], values[:, 0], values[:, 1], values[:, 2])
                        )
                        self._action_fcurves(action, obj, "rotation_quaternion", 4, frames, quaternions, group_name)
                    elif path == "scale":
                        self._action_fcurves(action, obj, "scale", 3, frames, values, group_name)
                    elif path == "weights" and obj.data.shape_keys:
                        key = obj.data.shape_keys
                        if shape_key_action is None:
                            key.animation_data_create()
                            shape_key_action = self._track(
                                bpy.data.actions,
                                bpy.data.actions.new(
                                    "{} / {} Keys".format(animation.get("name", "Animation"), obj.name)
                                ),
                            )
                            key.animation_data.action = shape_key_action
                        blocks = key.key_blocks
                        for shape_index in range(min(width, len(blocks) - 1)):
                            self._action_fcurves(
                                shape_key_action,
                                key,
                                blocks[shape_index + 1].path_from_id("value"),
                                1,
                                frames,
                                values[:, shape_index:shape_index + 1],
                                group_name,
                            )
                if shape_key_action is not None:
                    self._bind_action_slot(obj.data.shape_keys.animation_data, shape_key_action)
                self._bind_action_slot(obj.animation_data, action)
        self.context.scene.frame_end = max(self.context.scene.frame_end, math.ceil(last_frame))

    def _action_fcurves(self, action, target, data_path, width, frames, values, group_name):
        """Create fcurves directly with bulk keyframe assignment.

        Orders of magnitude faster than per-key keyframe_insert, which
        re-resolves the RNA path and searches fcurves on every call.
        """
        count = min(len(frames), len(values))
        if not count:
            return
        co = np.empty(count * 2, dtype=np.float64)
        co[0::2] = frames[:count]
        for axis in range(width):
            fcurve = self._new_fcurve(action, target, data_path, axis, group_name)
            fcurve.keyframe_points.add(count)
            co[1::2] = values[:count, axis]
            fcurve.keyframe_points.foreach_set("co", co)
            fcurve.update()

    def _new_fcurve(self, action, target, data_path, index, group_name):
        fcurves = getattr(action, "fcurves", None)
        if fcurves is not None:
            # Blender <= 4.x legacy action API.
            return fcurves.new(data_path, index=index, action_group=group_name)
        channelbag = self._action_channelbags.get(action)
        if channelbag is None:
            slot = action.slots.new(id_type=target.id_type, name=target.name)
            layer = action.layers.new("Layer")
            strip = layer.strips.new(type="KEYFRAME")
            channelbag = strip.channelbag(slot, ensure=True)
            self._action_channelbags[action] = channelbag
        fcurve = channelbag.fcurves.new(data_path, index=index)
        if group_name:
            group = channelbag.groups.get(group_name) or channelbag.groups.new(group_name)
            try:
                fcurve.group = group
            except AttributeError:
                pass
        return fcurve

    @staticmethod
    def _bind_action_slot(animation_data, action):
        # Blender 4.4+ slotted actions: fcurves created via the legacy API get
        # an implicit slot that still needs an explicit user binding.
        slots = getattr(action, "slots", None)
        if slots and hasattr(animation_data, "action_slot") and animation_data.action_slot is None:
            animation_data.action_slot = slots[0]

    def _animation_sampler(self, sampler):
        return {
            "times": self._read_accessor(sampler["input"]),
            "values": self._read_accessor(sampler["output"]),
            "width": int(sampler["output"].get("itemSize", 1)),
            "interpolation": sampler.get("interpolation", "LINEAR"),
        }

    def _create_armature_actions(self, animation, samplers, fps):
        transform_paths = {"translation", "rotation", "scale"}
        channels_by_joint = {}
        for channel in animation.get("channels", []):
            if channel.get("targetPath") in transform_paths:
                channels_by_joint.setdefault(channel.get("targetNode"), []).append(channel)
        last_frame = 0.0
        shared_actions = {}
        for skin_id, bone_names in self.armature_bones.items():
            animated = {joint: channels_by_joint[joint] for joint in bone_names if joint in channels_by_joint}
            if not animated:
                continue
            armature = self.armatures[skin_id]
            signature = tuple(
                (
                    joint_id,
                    bone_names[joint_id],
                    tuple(round(value, 8) for row in armature.data.bones[bone_names[joint_id]].matrix_local for value in row),
                )
                for joint_id in animated
            )
            armature.animation_data_create()
            if signature in shared_actions:
                action = self._track(bpy.data.actions, shared_actions[signature].copy())
                action.name = "{} / {}".format(animation.get("name", "Animation"), armature.name)
                action_slot = action.slots[0] if hasattr(action, "slots") and action.slots else None
                if action_slot is not None:
                    action_slot.name_display = armature.name
                armature.animation_data.action = action
                if action_slot is not None and hasattr(armature.animation_data, "action_slot"):
                    armature.animation_data.action_slot = action_slot
                continue
            action = self._track(
                bpy.data.actions,
                bpy.data.actions.new("{} / {}".format(animation.get("name", "Animation"), armature.name)),
            )
            armature.animation_data.action = action
            shared_actions[signature] = action
            for joint_id, channels in animated.items():
                pose_bone = armature.pose.bones.get(bone_names[joint_id])
                if pose_bone is None:
                    continue
                times = sorted({
                    float(time)
                    for channel in channels
                    for time in samplers[int(channel["sampler"])]["times"]
                })
                rest_translation, rest_rotation, rest_scale = _transform_components(
                    self.node_defs.get(joint_id, {}).get("localTransform", {})
                )
                channels_by_path = {channel["targetPath"]: channel for channel in channels}
                bone = armature.data.bones[pose_bone.name]
                rest_local = bone.parent.matrix_local.inverted_safe() @ bone.matrix_local \
                    if bone.parent else bone.matrix_local.copy()
                bind_scale = self.armature_bind_scales.get(skin_id, 1.0)
                bind_scale_matrix = Matrix.Scale(bind_scale, 4)
                inverse_bind_scale_matrix = Matrix.Scale(
                    1 / bind_scale if bind_scale > 1e-8 else 1, 4
                )
                previous_rotation = None
                pose_bone.rotation_mode = "QUATERNION"
                count = len(times)
                frames = np.empty(count, dtype=np.float64)
                locations = np.empty((count, 3), dtype=np.float64)
                rotations = np.empty((count, 4), dtype=np.float64)
                scales = np.empty((count, 3), dtype=np.float64)
                for index, time in enumerate(times):
                    translation = self._sample_animation_path(
                        channels_by_path.get("translation"), samplers, time, rest_translation
                    )
                    rotation = self._sample_animation_path(
                        channels_by_path.get("rotation"), samplers, time, rest_rotation, quaternion=True
                    )
                    scale = self._sample_animation_path(
                        channels_by_path.get("scale"), samplers, time, rest_scale
                    )
                    source_target = Matrix.LocRotScale(
                        Vector(translation),
                        Quaternion((rotation[3], rotation[0], rotation[1], rotation[2])),
                        Vector(scale),
                    )
                    # Keep the animated pose in the same normalized space as
                    # inverse_bind^-1 @ bind_scale used for the edit bones.
                    target_local = inverse_bind_scale_matrix @ source_target @ bind_scale_matrix
                    location, rotation_quaternion, pose_scale = \
                        (rest_local.inverted_safe() @ target_local).decompose()
                    if previous_rotation is not None and previous_rotation.dot(rotation_quaternion) < 0:
                        rotation_quaternion.negate()
                    previous_rotation = rotation_quaternion.copy()
                    frame = time * fps
                    last_frame = max(last_frame, frame)
                    frames[index] = frame
                    locations[index] = location
                    rotations[index] = (
                        rotation_quaternion.w,
                        rotation_quaternion.x,
                        rotation_quaternion.y,
                        rotation_quaternion.z,
                    )
                    scales[index] = pose_scale
                self._action_fcurves(
                    action, armature, pose_bone.path_from_id("location"), 3, frames, locations, pose_bone.name
                )
                self._action_fcurves(
                    action, armature, pose_bone.path_from_id("rotation_quaternion"), 4, frames, rotations, pose_bone.name
                )
                self._action_fcurves(
                    action, armature, pose_bone.path_from_id("scale"), 3, frames, scales, pose_bone.name
                )
            self._bind_action_slot(armature.animation_data, action)
        return last_frame

    def _sample_animation_path(self, channel, samplers, time, default, quaternion=False):
        if channel is None:
            return default
        sampler = samplers[int(channel["sampler"])]
        times = sampler["times"]
        values = sampler["values"]
        width = sampler["width"]
        if not len(times):
            return default

        def value_at(index, part=1):
            offset = index * width
            if sampler["interpolation"] == "CUBICSPLINE":
                offset = (index * 3 + part) * width
            return values[offset:offset + width]

        if time <= times[0]:
            return value_at(0)
        if time >= times[-1]:
            return value_at(len(times) - 1)
        right = bisect_left(times, time)
        if times[right] == time:
            return value_at(right)
        left = right - 1
        duration = float(times[right] - times[left])
        factor = (time - times[left]) / duration
        if sampler["interpolation"] == "STEP":
            return value_at(left)
        if sampler["interpolation"] == "CUBICSPLINE":
            p0, m0 = value_at(left), value_at(left, 2)
            p1, m1 = value_at(right), value_at(right, 0)
            t2, t3 = factor * factor, factor * factor * factor
            result = [
                (2 * t3 - 3 * t2 + 1) * p0[i]
                + (t3 - 2 * t2 + factor) * duration * m0[i]
                + (-2 * t3 + 3 * t2) * p1[i]
                + (t3 - t2) * duration * m1[i]
                for i in range(width)
            ]
            if quaternion:
                length = math.sqrt(sum(component * component for component in result))
                if length > 1e-8:
                    result = [component / length for component in result]
            return result
        if quaternion:
            left_value, right_value = value_at(left), value_at(right)
            result = Quaternion((left_value[3], *left_value[:3])).slerp(
                Quaternion((right_value[3], *right_value[:3])), factor
            )
            return (result.x, result.y, result.z, result.w)
        left_value, right_value = value_at(left), value_at(right)
        return [left_value[i] + (right_value[i] - left_value[i]) * factor for i in range(width)]

    def _apply_viewer_settings(self):
        scene = self.context.scene
        viewer = self.scene.get("viewer") or {}
        background = viewer.get("background") or {}
        world = scene.world
        if world is None:
            world = self._track(bpy.data.worlds, bpy.data.worlds.new("3DRipper World"))
            self._previous_world = None
            self._world_created = True
        scene.world = world
        world.use_nodes = True
        color = background.get("color") or background.get("backgroundColor") or (0.05, 0.05, 0.05)
        if isinstance(color, (list, tuple)) and len(color) >= 3:
            world.node_tree.nodes["Background"].inputs["Color"].default_value = _rgba(color, (0.05, 0.05, 0.05, 1))
        environment = viewer.get("environment") or {}
        strength = environment.get("exposure", environment.get("brightness", 1))
        if isinstance(strength, (int, float)):
            world.node_tree.nodes["Background"].inputs["Strength"].default_value = max(float(strength), 0)
        self._apply_environment_resource(world, viewer.get("resources") or {}, environment)
        self._pack_viewer_background(viewer.get("resources") or {})
        scene["sketchfab_viewer_options"] = json.dumps(viewer)
        self._import_viewer_camera(viewer.get("camera") or {})
        self._import_viewer_lights(viewer.get("lighting") or {})
        initial = self.scene.get("initialCamera")
        if initial:
            camera = next((obj for obj in self.objects.values() if obj.type == "CAMERA"), None)
            if camera:
                scene.camera = camera
        post_process = viewer.get("postProcess") or (viewer.get("scene") or {}).get("postProcess") or {}
        fog = viewer.get("fog") or (viewer.get("scene") or {}).get("fog") or post_process.get("fog") or {}
        if isinstance(fog, dict):
            self._apply_fog(world, fog)
        if isinstance(post_process, dict):
            scene["sketchfab_post_process"] = json.dumps(post_process)
            self._apply_post_process(scene, post_process)
        self._apply_material_effect_settings(scene)

    def _apply_material_effect_settings(self, scene):
        applied = []
        if self.has_subsurface:
            applied.append("sss:principled-subsurface")
        if self.has_refraction:
            eevee = getattr(scene, "eevee", None)
            if eevee:
                eevee.use_raytracing = True
                eevee.ray_tracing_method = "SCREEN"
            applied.append("refraction:principled-transmission")
        if applied:
            scene["sketchfab_material_effects_applied"] = ",".join(applied)

    def _apply_fog(self, world, settings):
        if settings.get("enable") is not True:
            return
        nodes, links = world.node_tree.nodes, world.node_tree.links
        volume = nodes.get("3DRipper Fog") or nodes.new("ShaderNodeVolumePrincipled")
        volume.name = "3DRipper Fog"
        color = settings.get("color") or settings.get("fogColor") or (0.5, 0.5, 0.5)
        density = settings.get("density", settings.get("factor", settings.get("intensity")))
        if density is None:
            start, end = float(settings.get("start", 0)), float(settings.get("end", 50))
            density = 1 / max(end - start, 1)
        volume.inputs["Color"].default_value = _rgba(color, (0.5, 0.5, 0.5, 1))
        volume.inputs["Density"].default_value = max(float(density), 0)
        volume.inputs["Anisotropy"].default_value = min(max(float(settings.get("anisotropy", 0)), -1), 1)
        output = next(node for node in nodes if node.type == "OUTPUT_WORLD")
        links.new(volume.outputs["Volume"], output.inputs["Volume"])
        world["sketchfab_fog_mode"] = "world-volume"

    def _apply_environment_resource(self, world, resources, environment):
        resources = resources if isinstance(resources, dict) else {}
        image = None
        resource = resources.get("environmentLuv")
        if isinstance(resource, dict) and resource.get("resourcePath"):
            try:
                image = self._decode_luv_environment(resource)
                world["sketchfab_environment_mode"] = "luv-panorama-hdr"
            except (EOFError, OSError, PackageError, ValueError) as error:
                world["sketchfab_environment_mode"] = "preview-fallback"
                world["sketchfab_environment_warning"] = str(error)

        if image is None:
            resource = resources.get("environmentPreview")
            if not isinstance(resource, dict) or not resource.get("resourcePath"):
                return
            path = self.package.materialize_resource(resource["resourcePath"])
            image = self._track(bpy.data.images, bpy.data.images.load(str(path), check_existing=False))
            image["sketchfab_viewer_resource"] = "environmentPreview"
            if self.pack_images:
                image.pack()
            world["sketchfab_environment_mode"] = "preview-panorama"

        nodes = world.node_tree.nodes
        links = world.node_tree.links
        environment_node = nodes.get("3DRipper Environment") or nodes.new("ShaderNodeTexEnvironment")
        environment_node.name = "3DRipper Environment"
        environment_node.label = "3DRipper HDR World" if image.get("sketchfab_viewer_resource") == "environmentLuv" else "3DRipper Environment Preview"
        environment_node.image = image
        mapping = nodes.get("3DRipper Environment Mapping") or nodes.new("ShaderNodeMapping")
        mapping.name = "3DRipper Environment Mapping"
        mapping.inputs["Rotation"].default_value[2] = float(environment.get("rotation", 0))
        coordinates = nodes.get("3DRipper Environment Coordinates") or nodes.new("ShaderNodeTexCoord")
        coordinates.name = "3DRipper Environment Coordinates"
        links.new(coordinates.outputs["Normal"], mapping.inputs["Vector"])
        links.new(mapping.outputs["Vector"], environment_node.inputs["Vector"])
        links.new(environment_node.outputs["Color"], nodes["Background"].inputs["Color"])

    def _decode_luv_environment(self, resource):
        width = resource.get("width")
        height = resource.get("height")
        base_level = resource.get("baseLevel")
        if (
            resource.get("encoding") != "luv"
            or not isinstance(width, int)
            or isinstance(width, bool)
            or not isinstance(height, int)
            or isinstance(height, bool)
            or not isinstance(base_level, dict)
        ):
            raise PackageError("Invalid 3DRipper LUV environment metadata")
        base_height = base_level.get("height")
        row_offset = base_level.get("rowOffset")
        if (
            width < 2
            or width % 2
            or height < width // 2
            or width * height > MAX_ENVIRONMENT_PIXELS
            or not isinstance(base_height, int)
            or isinstance(base_height, bool)
            or base_height != width // 2
            or not isinstance(row_offset, int)
            or isinstance(row_offset, bool)
            or row_offset < 0
            or row_offset + base_height > height
        ):
            raise PackageError("Unsupported 3DRipper LUV environment dimensions")

        expected_bytes = width * height * 4
        data = self.package.read_bytes(resource["resourcePath"])
        if data[:2] == b"\x1f\x8b":
            with gzip.GzipFile(fileobj=io.BytesIO(data)) as compressed:
                data = compressed.read(expected_bytes + 1)
        if len(data) != expected_bytes:
            raise PackageError("3DRipper LUV environment data length does not match its metadata")

        # Planar RGBA atlas: four consecutive width*height planes. Vectorized
        # LUV -> linear RGB decode over the base mip window, flipped vertically
        # to match Blender's bottom-up pixel order.
        planes = np.frombuffer(data, dtype=np.uint8).reshape(4, height, width).astype(np.float32)
        window = planes[:, row_offset:row_offset + base_height, :]
        red, green, blue, alpha = window[0], window[1], window[2], window[3]
        luminance_exponent = blue + alpha / 255.0
        y = np.exp2((luminance_exponent - 127.0) / 2.0)
        z = y / np.maximum(green / 255.0, 1e-6)
        x_value = red / 255.0 * z
        rgba = np.empty((base_height, width, 4), dtype=np.float32)
        rgba[..., 0] = np.clip(6.0013 * x_value - 1.332 * y + 0.3007 * z, 0.0, MAX_ENVIRONMENT_RGB)[::-1]
        rgba[..., 1] = np.clip(-2.700 * x_value + 3.1029 * y - 1.088 * z, 0.0, MAX_ENVIRONMENT_RGB)[::-1]
        rgba[..., 2] = np.clip(-1.7995 * x_value - 5.772 * y + 5.6268 * z, 0.0, MAX_ENVIRONMENT_RGB)[::-1]
        rgba[..., 3] = 1.0
        pixels = rgba.ravel()

        image = self._track(
            bpy.data.images,
            bpy.data.images.new("3DRipper Environment HDR", width=width, height=base_height, alpha=True, float_buffer=True),
        )
        image["sketchfab_viewer_resource"] = "environmentLuv"
        image["sketchfab_environment_encoding"] = "luv"
        try:
            image.colorspace_settings.name = "Linear Rec.709"
        except (TypeError, ValueError):
            try:
                image.colorspace_settings.name = "Linear"
            except (TypeError, ValueError):
                pass
        image.pixels.foreach_set(pixels)
        image.update()
        return image

    def _pack_viewer_background(self, resources):
        resource = resources.get("background") if isinstance(resources, dict) else None
        if not isinstance(resource, dict) or not resource.get("resourcePath"):
            return
        path = self.package.materialize_resource(resource["resourcePath"])
        image = self._track(bpy.data.images, bpy.data.images.load(str(path), check_existing=False))
        image.name = "3DRipper Viewer Background"
        image["sketchfab_viewer_resource"] = "background"
        if self.pack_images:
            image.pack()

    def _import_viewer_camera(self, camera_def):
        if not isinstance(camera_def, dict) or not camera_def.get("position"):
            return
        data = self._track(bpy.data.cameras, bpy.data.cameras.new("3DRipper Viewer Camera"))
        data.sensor_fit = "VERTICAL"
        fov = float(camera_def.get("fov", 50))
        data.angle = math.radians(fov) if fov > math.pi else fov
        obj = bpy.data.objects.new(data.name, data)
        self.collection.objects.link(obj)
        root_matrix = self._viewer_root_matrix()
        obj.location = root_matrix @ Vector(tuple(camera_def["position"])[:3])
        target = root_matrix @ Vector(tuple(camera_def.get("target", (0, 0, 0)))[:3])
        direction = target - obj.location
        if direction.length > 1e-8:
            obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
        self.objects["__viewer_camera__"] = obj
        self.context.scene.camera = obj

    def _import_viewer_lights(self, lighting):
        if not isinstance(lighting, dict) or lighting.get("enable") is False:
            return
        for index, definition in enumerate(lighting.get("lights") or []):
            if not isinstance(definition, dict) or definition.get("enable") is False:
                continue
            kind = str(definition.get("type", "DIRECTION")).upper()
            light_type = "SUN" if kind in ("DIRECTION", "DIRECTIONAL") else "SPOT" if kind == "SPOT" else "POINT"
            data = self._track(bpy.data.lights, bpy.data.lights.new("3DRipper Viewer Light {}".format(index + 1), light_type))
            data.color = tuple(definition.get("color", (1, 1, 1)))[:3]
            data.energy = float(definition.get("intensity", 1)) * (10 if light_type == "SUN" else 1000)
            data.use_shadow = bool(definition.get("castShadows", True))
            obj = bpy.data.objects.new(data.name, data)
            matrix = definition.get("matrix")
            if isinstance(matrix, list) and len(matrix) == 16:
                source_matrix = Matrix([matrix[row:row + 4] for row in range(0, 16, 4)]).transposed()
                obj.matrix_local = self._viewer_root_matrix() @ source_matrix
            self.collection.objects.link(obj)
            self.objects["__viewer_light_{}__".format(index)] = obj

    def _viewer_root_matrix(self):
        # The converter may add this synthetic root to bake 3DRipper's initial
        # view into the model. Viewer cameras and lights remain in source-world
        # coordinates, so they need the same transform to preserve their
        # relationship with the imported scene.
        root = self.objects.get("__blender_scene_root__")
        return root.matrix_world.copy() if root is not None else Matrix.Identity(4)

    def _apply_post_process(self, scene, settings):
        if settings.get("enable") is False:
            return
        applied = []
        eevee = getattr(scene, "eevee", None)
        ssao = settings.get("ssao") or {}
        if eevee and isinstance(ssao, dict) and ssao.get("enable") is True:
            eevee.use_fast_gi = True
            eevee.fast_gi_method = "AMBIENT_OCCLUSION_ONLY"
            eevee.fast_gi_distance = max(float(ssao.get("radius", 0.2)), 0.001)
            eevee.fast_gi_bias = max(float(ssao.get("bias", 0.003)), 0)
            eevee.fast_gi_quality = min(max(float(ssao.get("intensity", 1)), 0), 1)
            applied.append("ssao:eevee-fast-gi")
        ssr = settings.get("ssr") or {}
        if eevee and isinstance(ssr, dict) and ssr.get("enable") is True:
            eevee.use_raytracing = True
            eevee.ray_tracing_method = "SCREEN"
            applied.append("ssr:eevee-screen-trace")
        taa = settings.get("taa") or {}
        if eevee and isinstance(taa, dict) and taa.get("enable") is True:
            eevee.use_taa_reprojection = True
            applied.append("taa:eevee-reprojection")
        if self._apply_dof(scene, settings.get("dof") or {}):
            applied.append("dof:camera-native")

        tone_mapping = settings.get("toneMapping") or {}
        if isinstance(tone_mapping, dict) and tone_mapping.get("enable") is not False:
            scene.view_settings.look = "AgX - Medium High Contrast" if float(tone_mapping.get("contrast", 0)) > 0 else "AgX - Medium Low Contrast"
            scene.view_settings.exposure = float(tone_mapping.get("exposure", 1)) - 1
            applied.append("tone-mapping:agx")

        compositor_effects = (
            (isinstance(tone_mapping, dict) and tone_mapping.get("enable") is not False
             and any(float(tone_mapping.get(key, default)) != default
                     for key, default in (("brightness", 0), ("contrast", 0), ("saturation", 1)))),
            self._effect_enabled(settings, "bloom"),
            self._effect_enabled(settings, "sharpen"),
            self._effect_enabled(settings, "vignette"),
            self._effect_enabled(settings, "chromaticAberration"),
            self._effect_enabled(settings, "colorBalance"),
            self._effect_enabled(settings, "grain"),
        )
        if not any(compositor_effects):
            scene["sketchfab_post_process_applied"] = ",".join(applied)
            return

        node_tree, output = self._create_compositor(scene)
        nodes = node_tree.nodes
        links = node_tree.links
        render = nodes.new("CompositorNodeRLayers")
        render.name = "3DRipper Render"
        image = render.outputs["Image"]

        if compositor_effects[0]:
            color = nodes.new("CompositorNodeBrightContrast")
            color.name = "3DRipper Tone Adjustments"
            self._set_input(color, ("Bright", "Brightness"), float(tone_mapping.get("brightness", 0)) * 100)
            self._set_input(color, ("Contrast",), float(tone_mapping.get("contrast", 0)) * 100)
            links.new(image, color.inputs["Image"])
            image = color.outputs["Image"]
            saturation = nodes.new("CompositorNodeHueSat")
            saturation.name = "3DRipper Saturation"
            self._set_input(saturation, ("Saturation",), max(float(tone_mapping.get("saturation", 1)), 0))
            links.new(image, saturation.inputs["Image"])
            image = saturation.outputs["Image"]
            applied.append("tone-adjustments:compositor")

        if compositor_effects[5]:
            balance = settings["colorBalance"]
            node = nodes.new("CompositorNodeColorBalance")
            node.name = "3DRipper Color Balance"
            for source, target in (("low", "Lift"), ("mid", "Gamma"), ("high", "Gain")):
                value = _vec3(balance.get(source), (0, 0, 0))
                mapped = tuple(max(1 + item, 0.01) for item in value)
                property_name = target.lower()
                if getattr(node, property_name, None) is not None:
                    setattr(node, property_name, mapped)
                else:
                    self._set_color_input(node, target, mapped + (1,))
            links.new(image, self._input(node, ("Image",)))
            image = node.outputs["Image"]
            applied.append("color-balance:compositor")

        if compositor_effects[4]:
            effect = settings["chromaticAberration"]
            lens = nodes.new("CompositorNodeLensdist")
            lens.name = "3DRipper Chromatic Aberration"
            self._set_input(lens, ("Dispersion",), max(float(effect.get("factor", 0.05)), 0))
            links.new(image, lens.inputs["Image"])
            image = lens.outputs["Image"]
            applied.append("chromatic-aberration:compositor")

        if compositor_effects[2]:
            effect = settings["sharpen"]
            sharpen = nodes.new("CompositorNodeFilter")
            sharpen.name = "3DRipper Sharpen"
            if getattr(sharpen, "filter_type", None) is not None:
                sharpen.filter_type = "SHARPEN"
            else:
                self._set_input(sharpen, ("Type",), "Sharpen")
            self._set_input(sharpen, ("Factor", "Fac"), max(float(effect.get("factor", 0.2)), 0))
            links.new(image, sharpen.inputs["Image"])
            image = sharpen.outputs["Image"]
            applied.append("sharpen:compositor")

        if compositor_effects[1]:
            bloom = settings["bloom"]
            glare = nodes.new("CompositorNodeGlare")
            glare.name = "3DRipper Bloom"
            if getattr(glare, "glare_type", None) is not None:
                glare.glare_type = "FOG_GLOW"
                glare.quality = "HIGH"
                self._set_input(glare, ("Threshold",), float(bloom.get("threshold", 1)))
                glare.mix = min(max(float(bloom.get("factor", 0.5)), 0), 1) - 1
                glare.size = max(6, min(9, round(6 + float(bloom.get("radius", 0.7)) * 3)))
            else:
                self._set_input(glare, ("Type",), "Fog Glow")
                self._set_input(glare, ("Quality",), "High")
                self._set_input(glare, ("Threshold",), float(bloom.get("threshold", 1)))
                self._set_input(glare, ("Strength",), max(float(bloom.get("factor", 0.5)), 0))
                self._set_input(glare, ("Size",), min(max(float(bloom.get("radius", 0.7)), 0), 1))
            links.new(image, glare.inputs["Image"])
            image = glare.outputs["Image"]
            applied.append("bloom:compositor")

        if compositor_effects[6]:
            image = self._add_grain(node_tree, image, settings["grain"])
            applied.append("grain:compositor-noise")

        if compositor_effects[3]:
            image = self._add_vignette(node_tree, image, settings["vignette"])
            applied.append("vignette:compositor")

        links.new(image, output)
        scene["sketchfab_post_process_applied"] = ",".join(applied)

    def _apply_dof(self, scene, settings):
        if not isinstance(settings, dict) or settings.get("enable") is not True or not scene.camera:
            return False
        blur = max(float(settings.get("blurNear", 0)), float(settings.get("blurFar", 0)))
        if blur < 0.01:
            return False
        focus = bpy.data.objects.new("3DRipper DOF Focus", None)
        focus.empty_display_type = "PLAIN_AXES"
        focus.empty_display_size = 0.05
        focus.location = self._viewer_root_matrix() @ Vector(_vec3(settings.get("focusPoint"), (0, 0, 0)))
        self.collection.objects.link(focus)
        dof = scene.camera.data.dof
        dof.use_dof = True
        dof.focus_object = focus
        dof.aperture_fstop = max(0.5, 2.8 / max(blur * 2, 0.25))
        dof.aperture_blades = 6
        return True

    @staticmethod
    def _effect_enabled(settings, name):
        effect = settings.get(name) or {}
        return isinstance(effect, dict) and effect.get("enable") is True

    def _create_compositor(self, scene):
        if hasattr(scene, "compositing_node_group"):
            node_tree = self._track(bpy.data.node_groups, bpy.data.node_groups.new("3DRipper Post Process", "CompositorNodeTree"))
            scene.compositing_node_group = node_tree
            node_tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor")
            output = node_tree.nodes.new("NodeGroupOutput")
            output.name = "3DRipper Composite"
            return node_tree, output.inputs["Image"]
        scene.use_nodes = True
        node_tree = scene.node_tree
        node_tree.nodes.clear()
        output = node_tree.nodes.new("CompositorNodeComposite")
        output.name = "3DRipper Composite"
        return node_tree, output.inputs["Image"]

    def _add_vignette(self, node_tree, image, settings):
        nodes, links = node_tree.nodes, node_tree.links
        ellipse = nodes.new("CompositorNodeEllipseMask")
        ellipse.name = "3DRipper Vignette Mask"
        self._set_input(ellipse, ("Position",), (0.5, 0.5))
        self._set_input(ellipse, ("Size",), (1.15, 1.15))
        blur = nodes.new("CompositorNodeBlur")
        blur.name = "3DRipper Vignette Softness"
        hardness = min(max(float(settings.get("hardness", 0.5)), 0), 1)
        if "Size" in blur.inputs:
            blur.inputs["Size"].default_value = (max(int((1 - hardness) * 500), 1),) * 2
        else:
            blur.use_relative = True
            blur.factor_x = blur.factor_y = max((1 - hardness) * 20, 0.1)
        links.new(ellipse.outputs["Mask"], blur.inputs["Image"])
        invert = nodes.new("CompositorNodeInvert")
        invert.name = "3DRipper Vignette Invert"
        links.new(blur.outputs["Image"], self._input(invert, ("Color", "Image")))
        multiply = self._new_compositor_math(nodes)
        multiply.name = "3DRipper Vignette Amount"
        multiply.operation = "MULTIPLY"
        multiply.inputs[1].default_value = min(max(float(settings.get("amount", 0.25)), 0), 1)
        links.new(self._output(invert, ("Color", "Image")), multiply.inputs[0])
        mix = self._new_compositor_mix(nodes)
        mix.name = "3DRipper Vignette"
        mix.inputs[2].default_value = (0, 0, 0, 1)
        links.new(multiply.outputs[0], mix.inputs[0])
        links.new(image, mix.inputs[1])
        return mix.outputs[0]

    def _add_grain(self, node_tree, image, settings):
        nodes, links = node_tree.nodes, node_tree.links
        if hasattr(self.context.scene, "compositing_node_group"):
            coordinates = nodes.new("CompositorNodeImageCoordinates")
            coordinates.name = "3DRipper Grain Coordinates"
            links.new(image, coordinates.inputs["Image"])
            noise = nodes.new("ShaderNodeTexNoise")
            noise.name = "3DRipper Grain Noise"
            noise.noise_dimensions = "4D"
            noise.inputs["Scale"].default_value = 700
            noise.inputs["Detail"].default_value = 0
            links.new(coordinates.outputs["Normalized"], noise.inputs["Vector"])
            if settings.get("animated") is True:
                noise.inputs["W"].driver_add("default_value").driver.expression = "frame / 24.0"
            noise_output = self._output(noise, ("Color",))
        else:
            texture = bpy.data.textures.get("3DRipper Grain Texture") \
                or self._track(bpy.data.textures, bpy.data.textures.new("3DRipper Grain Texture", type="CLOUDS"))
            texture.noise_scale = 0.01
            texture.noise_depth = 0
            noise = nodes.new("CompositorNodeTexture")
            noise.name = "3DRipper Grain Noise"
            noise.texture = texture
            noise.inputs["Scale"].default_value = (100, 100, 1)
            if settings.get("animated") is True:
                for index, expression in enumerate(("frame * 0.017", "frame * 0.031")):
                    noise.inputs["Offset"].driver_add("default_value", index).driver.expression = expression
            noise_output = self._output(noise, ("Color",))
        mix = self._new_compositor_mix(nodes)
        mix.name = "3DRipper Grain"
        mix.blend_type = "OVERLAY"
        self._set_input(mix, ("Factor", "Fac"), min(max(float(settings.get("factor", 0.15)), 0), 1))
        links.new(image, mix.inputs[1])
        links.new(noise_output, mix.inputs[2])
        return self._output(mix, ("Color", "Image"))

    @staticmethod
    def _new_compositor_math(nodes):
        try:
            return nodes.new("CompositorNodeMath")
        except RuntimeError:
            return nodes.new("ShaderNodeMath")

    @staticmethod
    def _new_compositor_mix(nodes):
        try:
            return nodes.new("CompositorNodeMixRGB")
        except RuntimeError:
            return nodes.new("ShaderNodeMixRGB")

    def _create_initial_camera(self):
        camera_def = self.scene["initialCamera"]
        camera = self._track(bpy.data.cameras, bpy.data.cameras.new(camera_def.get("name", "InitialViewCamera")))
        camera.sensor_fit = "VERTICAL"
        camera.angle = float(camera_def.get("yfov", math.radians(50)))
        camera.clip_start = max(float(camera_def.get("znear", 0.01)), 0.0001)
        camera.clip_end = float(camera_def.get("zfar", 1000))
        obj = bpy.data.objects.new(camera.name, camera)
        self.collection.objects.link(obj)
        self._apply_transform(obj, {"matrix": camera_def.get("matrix")})
        self.objects["__initial_camera__"] = obj
        self.context.scene.camera = obj

    def _apply_transform(self, obj, transform):
        matrix = transform.get("matrix")
        if matrix and len(matrix) == 16:
            obj.matrix_local = Matrix([matrix[index:index + 4] for index in range(0, 16, 4)]).transposed()
            return
        if transform.get("translation"):
            obj.location = tuple(transform["translation"])[:3]
        if transform.get("rotation") and len(transform["rotation"]) == 4:
            value = transform["rotation"]
            obj.rotation_mode = "QUATERNION"
            obj.rotation_quaternion = Quaternion((value[3], value[0], value[1], value[2]))
        if transform.get("scale"):
            obj.scale = tuple(transform["scale"])[:3]

    def _remove_import_collection(self):
        if self.collection:
            for obj in list(self.collection.objects):
                bpy.data.objects.remove(obj, do_unlink=True)
            bpy.data.collections.remove(self.collection)
            self.collection = None
        if self._world_created:
            self.context.scene.world = self._previous_world
        for collection, datablock in reversed(self._created_datablocks):
            try:
                collection.remove(datablock)
            except (ReferenceError, RuntimeError):
                pass
        self._created_datablocks = []

    @staticmethod
    def _input(node, names):
        for name in names:
            if name in node.inputs:
                return node.inputs[name]
        return None

    @staticmethod
    def _output(node, names):
        for name in names:
            if name in node.outputs:
                return node.outputs[name]
        return None

    def _set_input(self, node, names, value):
        target = self._input(node, names)
        if target is not None and hasattr(target, "default_value"):
            try:
                target.default_value = value
            except (AttributeError, TypeError, ValueError):
                pass

    @staticmethod
    def _set_color_input(node, name, value):
        target = next((item for item in node.inputs if item.name == name and item.bl_idname == "NodeSocketColor"), None)
        if target is not None:
            try:
                target.default_value = value
            except (AttributeError, TypeError, ValueError):
                pass


def _rgba(value, default):
    if not isinstance(value, (list, tuple)):
        return default
    result = list(value[:4])
    while len(result) < 4:
        result.append(1)
    return tuple(float(component) for component in result)


def _vec3(value, default):
    if isinstance(value, str):
        value = value.replace(",", " ").split()
    if not isinstance(value, (list, tuple)) or len(value) < 3:
        return default
    return tuple(float(component) for component in value[:3])


def _number(value, default):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        return default
    return float(value) if math.isfinite(value) else default


def _anisotropy_turns(value):
    rotation = _number(value, 0)
    return (rotation / math.tau) % 1


def _transform_components(transform):
    matrix = transform.get("matrix")
    if matrix and len(matrix) == 16:
        location, rotation, scale = Matrix([
            matrix[index:index + 4] for index in range(0, 16, 4)
        ]).transposed().decompose()
        return location, (rotation.x, rotation.y, rotation.z, rotation.w), scale
    translation = transform.get("translation") or (0, 0, 0)
    rotation = transform.get("rotation") or (0, 0, 0, 1)
    scale = transform.get("scale") or (1, 1, 1)
    return tuple(translation[:3]), tuple(rotation[:4]), tuple(scale[:3])
