"""Signed distance fields against every shape type they collide with.

agxCollide.SignedDistanceField collides with: SignedDistanceField, Box, Sphere, Capsule, Cylinder,
Convex, Trimesh, HeightField, Plane, Line, WireShape (wires) and shape groups (ShapeGroup, through
their shapes). It does not (yet) collide with Cone, HollowCone, HollowCylinder, TireShape or
TerrainWheel.

Scenes (keys 1-5):
1. A static signed distance field bowl, with boxes, spheres, capsules, cylinders, convexes,
   triangle meshes, shape groups and signed distance fields dropped in it.
2. A static triangle mesh bowl, with signed distance fields of different meshes dropped in it.
3. Signed distance fields dropped on static shapes of every type: plane, height field, box,
   sphere, capsule, cylinder, convex, triangle mesh and signed distance field.
4. Signed distance fields and primitives, all dynamic, dropped together on a bumpy height field.
5. Wires over grooved signed distance field sheaves, one static and one turning on a hinge,
   holding signed distance field weights. The key k switches the sheaves and weights between
   signed distance fields and triangle meshes of the same triangles.

Run: agxViewer signed_distance_field_shapes.agxPy, or python signed_distance_field_shapes.agxPy
See also data/python/tutorials/tutorial_signedDistanceField.agxPy.
"""

import math

import agx
import agxCollide
import agxIO
import agxOSG
import agxRender
import agxSDK
import agxWire

from agxPythonModules.utils.environment import application, init_app, root, simulation

# Python objects the simulation only holds raw pointers to.
_keep = []

# -----------------------------------------------------------------------------
# Shapes
# -----------------------------------------------------------------------------

# Meshes from data/models: (file, scale to about 0.1-0.2 m, voxel size of its field).
MESHES = {
    "torus": ("models/torusRound.obj", 0.05, 0.002),
    "link": ("models/Link_v2.obj", 0.0002, 0.001),
    "icosphere": ("models/icosphere2.obj", 0.05, 0.002),
    "rock": ("models/rock_lp_01.obj", 0.0012, 0.003),
    "bunny": ("models/bunny.obj", 1.0, 0.002),
    "knight": ("models/knight.obj", 1.0, 0.002),
    "mushroom": ("models/mushroom.obj", 0.02, 0.002),
    "gear": ("models/gear1.obj", 0.005, 0.001),
    "cup": ("models/cup.obj", 0.002, 0.002),
}

_meshes = {}
_grids = {}


def mesh(name):
    """The vertices and indices of a mesh in MESHES, scaled and centered."""
    if name not in _meshes:
        fileName, scale, _ = MESHES[name]
        reader = agxIO.MeshReader()
        assert reader.readFile(fileName), fileName
        bound = agxCollide.BoundingAABB()
        for vertex in reader.getVertices():
            bound.expand(vertex * scale)
        center = (bound.min() + bound.max()) * 0.5
        vertices = agx.Vec3Vector()
        for vertex in reader.getVertices():
            vertices.append(vertex * scale - center)
        # Copy: getIndices() refers into the reader.
        _meshes[name] = (vertices, agx.UInt32Vector(reader.getIndices()))
    return _meshes[name]


def field(name):
    """A signed distance field shape of a mesh in MESHES. Shapes of the same mesh share one grid."""
    if name not in _grids:
        options = agxCollide.SignedDistanceFieldGridOptions()
        options.voxelSize = MESHES[name][2]
        _grids[name] = agxCollide.SignedDistanceFieldGrid.createFromMesh(*mesh(name), options)
    return agxCollide.SignedDistanceField(_grids[name])


def trimesh(name):
    return agxCollide.Trimesh(*mesh(name), name)


def convex(name):
    return agxCollide.Convex(*mesh(name), name)


def bowlMesh():
    """models/bowl.obj, about 1.1 m across and 0.45 m high, base at z = 0."""
    reader = agxIO.MeshReader()
    assert reader.readFile("models/bowl.obj")
    vertices = agx.Vec3Vector()
    for vertex in reader.getVertices():
        vertices.append(vertex * 0.15)
    return vertices, agx.UInt32Vector(reader.getIndices())


def shapeGroup():
    """A dumbbell: two spheres and a box in one ShapeGroup."""
    group = agxCollide.ShapeGroup()
    group.addChild(agxCollide.Sphere(0.04), agx.AffineMatrix4x4.translate(-0.07, 0, 0))
    group.addChild(agxCollide.Sphere(0.04), agx.AffineMatrix4x4.translate(0.07, 0, 0))
    group.addChild(agxCollide.Box(agx.Vec3(0.07, 0.015, 0.015)))
    return group


def heightField(size, resolution, height):
    field = agxCollide.HeightField(resolution, resolution, size, size)
    for i in range(resolution):
        for j in range(resolution):
            x = (i / (resolution - 1) - 0.5) * size
            y = (j / (resolution - 1) - 0.5) * size
            field.setHeight(i, j, height(x, y))
    return field


# -----------------------------------------------------------------------------
# Bodies and graphics
# -----------------------------------------------------------------------------

COLORS = [
    agxRender.Color.Gold(),
    agxRender.Color.OrangeRed(),
    agxRender.Color.YellowGreen(),
    agxRender.Color.Violet(),
    agxRender.Color.Cyan(),
    agxRender.Color.Coral(),
    agxRender.Color.DeepSkyBlue(),
    agxRender.Color.HotPink(),
    agxRender.Color.Khaki(),
]


def addBody(shape, position, rotation=None, color=None):
    body = agx.RigidBody(agxCollide.Geometry(shape))
    body.setPosition(position)
    if rotation is not None:
        body.setRotation(rotation)
    simulation().add(body)
    node = agxOSG.createVisual(body, root())
    if color is not None:
        agxOSG.setDiffuseColor(node, color)
    return body


def addStatic(shape, position=agx.Vec3(), rotation=None, color=agxRender.Color.SteelBlue()):
    geometry = agxCollide.Geometry(shape)
    geometry.setPosition(position)
    if rotation is not None:
        geometry.setRotation(rotation)
    simulation().add(geometry)
    node = agxOSG.createVisual(geometry, root())
    if color is not None:
        agxOSG.setDiffuseColor(node, color)
    return geometry


def addPlane(halfSize):
    addStatic(agxCollide.Plane(agx.Vec3.Z_AXIS(), 0.0), color=None)
    # agxOSG does not draw planes: a thin box, only for the graphics.
    floor = addStatic(agxCollide.Box(agx.Vec3(halfSize, halfSize, 0.01)), agx.Vec3(0, 0, -0.01), color=agxRender.Color.LightGray())
    floor.setEnableCollisions(False)


def dropInRing(createShapes, layers, radius, height, layerHeight):
    """Drops each shape of each layer from a ring of positions, turned a little differently."""
    count = len(createShapes)
    for layer in range(layers):
        for k, create in enumerate(createShapes):
            angle = 2 * math.pi * (k + 0.5 * layer) / count
            position = agx.Vec3(radius * math.cos(angle), radius * math.sin(angle), height + layerHeight * layer)
            addBody(create(), position, agx.Quat(0.7 * k + layer, agx.Vec3(1, 1, 0)), COLORS[k % len(COLORS)])


def setText(lines):
    decorator = application().getSceneDecorator()
    for i, line in enumerate(lines):
        decorator.setText(i, line, agxRender.Color.White())


def setup(cameraEye, cameraCenter):
    # Small, thin shapes falling fast: keep the motion per step well below the thinnest feature.
    simulation().setTimeStep(1.0 / 240.0)
    # Collision detection and the solver run in parallel; four threads make a large difference here.
    # A thread count given on the command line (--numThreads) is kept.
    if application().getArguments().find("--numThreads") < 0:
        agx.setNumThreads(4)
    application().setCameraHome(cameraEye, cameraCenter, agx.Vec3.Z_AXIS())


# -----------------------------------------------------------------------------
# Scenes
# -----------------------------------------------------------------------------


def buildFieldBowl():
    """A static signed distance field bowl, with shapes of every type dropped in it."""
    setText(["Static signed distance field bowl. Dropped: box, sphere, capsule, cylinder,",
             "convex, triangle mesh, shape group and signed distance field."])
    setup(agx.Vec3(0.8, -1.1, 1.6), agx.Vec3(0, 0, 0.1))
    options = agxCollide.SignedDistanceFieldGridOptions()
    options.voxelSize = 0.005
    addStatic(agxCollide.SignedDistanceField(agxCollide.SignedDistanceFieldGrid.createFromMesh(*bowlMesh(), options)))
    addPlane(1.5)
    dropInRing([
        lambda: agxCollide.Box(agx.Vec3(0.05, 0.04, 0.03)),
        lambda: agxCollide.Sphere(0.05),
        lambda: agxCollide.Capsule(0.035, 0.08),
        lambda: agxCollide.Cylinder(0.045, 0.06),
        lambda: convex("icosphere"),
        lambda: trimesh("bunny"),
        shapeGroup,
        lambda: field("torus"),
    ], layers=3, radius=0.25, height=0.65, layerHeight=0.15)


def buildMeshBowl():
    """A static triangle mesh bowl, with signed distance fields of different meshes dropped in it."""
    setText(["Static triangle mesh bowl. Dropped: signed distance fields of a torus, a chain link,",
             "a rock, a bunny, a knight, a mushroom, a gear and a cup."])
    setup(agx.Vec3(0.8, -1.1, 1.6), agx.Vec3(0, 0, 0.1))
    addStatic(agxCollide.Trimesh(*bowlMesh(), "bowl"))
    addPlane(1.5)
    names = ["torus", "link", "rock", "bunny", "knight", "mushroom", "gear", "cup"]
    dropInRing([lambda name=name: field(name) for name in names], layers=2, radius=0.25, height=0.65, layerHeight=0.2)


def buildFieldsOnShapes():
    """Signed distance fields dropped on static shapes of every type."""
    setText(["Signed distance fields dropped on static shapes: plane, height field, box, sphere,",
             "capsule, cylinder, convex, triangle mesh and signed distance field."])
    setup(agx.Vec3(0.5, -3.2, 2.0), agx.Vec3(0, 0, 0.2))
    addPlane(2.5)
    # A bumpy height field to the left, the primitives in a row to the right.
    addStatic(heightField(1.2, 41, lambda x, y: 0.06 * (1 + math.sin(6 * x) * math.cos(6 * y))),
              agx.Vec3(-1.2, 0, 0), color=agxRender.Color.SandyBrown())
    statics = [
        (agxCollide.Box(agx.Vec3(0.15, 0.3, 0.05)), agx.Vec3(-0.3, 0, 0.25), agx.Quat(0.3, agx.Vec3.Y_AXIS())),
        (agxCollide.Sphere(0.15), agx.Vec3(0.05, 0, 0.15), None),
        (agxCollide.Capsule(0.06, 0.5), agx.Vec3(0.35, 0, 0.12), None),
        (agxCollide.Cylinder(0.08, 0.5), agx.Vec3(0.65, 0, 0.15), None),
        (convex("icosphere"), agx.Vec3(0.95, -0.15, 0.2), None),
        (trimesh("rock"), agx.Vec3(0.95, 0.2, 0.2), None),
        (field("bunny"), agx.Vec3(1.25, 0, 0.1), None),
    ]
    for shape, position, rotation in statics:
        addStatic(shape, position, rotation)
    names = ["rock", "torus", "link", "icosphere", "bunny", "gear"]
    for i in range(36):
        x = -1.6 + 0.09 * i
        y = -0.15 + 0.15 * (i % 3)
        addBody(field(names[i % len(names)]), agx.Vec3(x, y, 0.8 + 0.1 * (i % 4)),
                agx.Quat(0.9 * i, agx.Vec3(1, 1, 0)), COLORS[i % len(COLORS)])


def buildMixedPile():
    """Signed distance fields and primitives, all dynamic, dropped together on a bumpy height field."""
    setText(["Signed distance fields and primitives, all dynamic, dropped together on a height",
             "field: every shape type against fields, both bodies moving."])
    setup(agx.Vec3(1.6, -2.0, 1.5), agx.Vec3(0, 0, 0.2))
    addStatic(heightField(2.4, 61, lambda x, y: 0.15 * (x * x + y * y) + 0.03 * math.sin(8 * x) * math.sin(8 * y)),
              color=agxRender.Color.SandyBrown())
    creators = [
        lambda: field("rock"),
        lambda: agxCollide.Box(agx.Vec3(0.05, 0.04, 0.03)),
        lambda: field("torus"),
        lambda: agxCollide.Sphere(0.05),
        lambda: field("link"),
        lambda: agxCollide.Capsule(0.035, 0.08),
        lambda: field("bunny"),
        lambda: agxCollide.Cylinder(0.045, 0.06),
        lambda: field("knight"),
        lambda: convex("icosphere"),
        lambda: field("gear"),
        lambda: trimesh("rock"),
        lambda: field("mushroom"),
        shapeGroup,
    ]
    dropInRing(creators, layers=4, radius=0.35, height=0.5, layerHeight=0.15)


def sheaveMesh(outer, groove, width, segments=64, profileSteps=12):
    """A pulley wheel around the y axis, centered: a V groove from the radius outer at the sides
    down to the radius groove in the middle, closed by a hub. (vertices, indices)."""
    hub = 0.3 * outer
    profile = [(hub, -0.5 * width)]
    for k in range(profileSteps + 1):
        t = k / profileSteps
        profile.append((groove + (outer - groove) * abs(2 * t - 1), (t - 0.5) * width))
    profile.append((hub, 0.5 * width))
    n = len(profile)
    vertices = agx.Vec3Vector()
    for s in range(segments):
        angle = 2 * math.pi * s / segments
        for radius, y in profile:
            vertices.append(agx.Vec3(radius * math.cos(angle), y, radius * math.sin(angle)))
    indices = agx.UInt32Vector()

    def quad(a, b, c, d):
        for index in (a, c, b, b, c, d):
            indices.append(index)

    for s in range(segments):
        s1 = (s + 1) % segments
        for k in range(n - 1):
            quad(s * n + k, s1 * n + k, s * n + k + 1, s1 * n + k + 1)
        # The hub, from the last profile point back to the first.
        quad(s * n + n - 1, s1 * n + n - 1, s * n, s1 * n)
    return vertices, indices


def sizedMesh(fileName, size):
    """The vertices and indices of a mesh file, centered and scaled to the largest extent size."""
    reader = agxIO.MeshReader()
    assert reader.readFile(fileName), fileName
    bound = agxCollide.BoundingAABB()
    for vertex in reader.getVertices():
        bound.expand(vertex)
    extent = bound.max() - bound.min()
    scale = size / max(extent.x(), extent.y(), extent.z())
    center = (bound.min() + bound.max()) * 0.5
    vertices = agx.Vec3Vector()
    for vertex in reader.getVertices():
        vertices.append((vertex - center) * scale)
    return vertices, agx.UInt32Vector(reader.getIndices())


def addWire(nodes, radius, resolution=10):
    """A wire through the nodes, rendered."""
    wire = agxWire.Wire(radius, resolution)
    for node in nodes:
        wire.add(node)
    simulation().add(wire)
    simulation().add(agxOSG.WireRenderer(wire, root()))
    return wire


class SwitchShapes(agxSDK.GuiEventListener):
    """Switches shapes between signed distance fields and triangle meshes of the same triangles with
    the k key. Each entry is (geometry, field shape, mesh shape); the fields are used from start.
    The shapes must be held by references (e.g. agxCollide.TrimeshRef for a shallow copy, which
    Python does not own), or a shape is deleted when it is removed from its geometry."""

    KEY = "k"

    def __init__(self, shapes, textRow):
        super().__init__(agxSDK.GuiEventListener.KEYBOARD)
        self.shapes = shapes
        self.textRow = textRow
        self.useField = True
        self.showMode()

    def showMode(self):
        mode = "signed distance fields" if self.useField else "triangle meshes"
        application().getSceneDecorator().setText(self.textRow, f"Sheaves and weights: {mode}. Press {self.KEY} to switch.",
                                                  agxRender.Color.Yellow())

    def keyboard(self, key, x, y, alt, down):
        if not down or key != ord(self.KEY):
            return False
        self.useField = not self.useField
        for geometry, field, mesh in self.shapes:
            geometry.remove(mesh if self.useField else field)
            geometry.add(field if self.useField else mesh)
        self.showMode()
        return True


def buildWires():
    """Wires over signed distance field sheaves, or triangle mesh sheaves (key k)."""
    setText(["Wires over grooved sheaves, a static one (left) and one turning on a hinge (right),",
             "each holding a weight."])
    setup(agx.Vec3(0.3, -5.5, 2.4), agx.Vec3(0.3, 0, 1.5))
    addPlane(3.0)

    outer, groove, width = 0.3, 0.22, 0.16
    sheaveVertices, sheaveIndices = sheaveMesh(outer, groove, width)
    options = agxCollide.SignedDistanceFieldGridOptions()
    options.voxelSize = 0.005
    sheaveGrid = agxCollide.SignedDistanceFieldGrid.createFromMesh(sheaveVertices, sheaveIndices, options)
    sheaveTrimesh = agxCollide.Trimesh(sheaveVertices, sheaveIndices, "sheave")
    knightVertices, knightIndices = sizedMesh("models/knight.obj", 0.5)
    options.voxelSize = 0.5 / 80
    knightGrid = agxCollide.SignedDistanceFieldGrid.createFromMesh(knightVertices, knightIndices, options)
    knightTrimesh = agxCollide.Trimesh(knightVertices, knightIndices, "knight")
    wireRadius = 0.015

    # (geometry, field, mesh) of every shape that is switched with the key.
    shapes = []

    def meshCopy(trimesh):
        """A shallow copy of a mesh, held by a reference: Python does not own a shallow copy."""
        reference = agxCollide.TrimeshRef(trimesh.shallowCopy())
        _keep.append(reference)
        return reference.get()

    def keepMassProperties(body, mass):
        body.getMassProperties().setMass(mass)
        # The field and the mesh compute the inertia slightly differently: keep it when switching.
        body.getMassProperties().setAutoGenerateMask(agx.MassProperties.AUTO_GENERATE_NONE)

    for k, turning in enumerate((False, True)):
        center = agx.Vec3(-0.6 + 1.8 * k, 0, 2.2)
        field = agxCollide.SignedDistanceField(sheaveGrid)
        if turning:
            sheave = addBody(field, center, color=agxRender.Color.SteelBlue())
            keepMassProperties(sheave, 5.0)
            simulation().add(agx.Hinge(agx.HingeFrame(center, agx.Vec3.Y_AXIS()), sheave))
        else:
            addStatic(field, center, color=agxRender.Color.SlateGray())
        shapes.append((field.getGeometry(), field, meshCopy(sheaveTrimesh)))

        # From an anchor over the top of the groove, down to a weight.
        top = center.z() + groove + wireRadius
        side = center.x() + groove + wireRadius
        knight = agxCollide.SignedDistanceField(knightGrid)
        weight = addBody(knight, agx.Vec3(side, 0, 1.0), color=COLORS[k])
        keepMassProperties(weight, 20.0)
        shapes.append((knight.getGeometry(), knight, meshCopy(knightTrimesh)))
        addWire([agxWire.BodyFixedNode(None, agx.Vec3(center.x() - 1.0, 0, top)),
                 agxWire.FreeNode(agx.Vec3(center.x(), 0, top)),
                 agxWire.FreeNode(agx.Vec3(side, 0, center.z())),
                 agxWire.BodyFixedNode(weight, agx.Vec3(0, 0, 0.25))], wireRadius)

    switch = SwitchShapes(shapes, 2)
    simulation().add(switch)
    _keep.append(switch)


def addRemainingScenes():
    """Adds the other scenes so that they can be started with the keys 2-5 from agxViewer."""
    scriptFileName = application().getArguments().getArgumentName(1).replace("agxscene:", "")
    for name in ["buildMeshBowl", "buildFieldsOnShapes", "buildMixedPile", "buildWires"]:
        application().addScene(scriptFileName, name, ord(str(application().getNumScenes() + 1)), True)


def buildScene():
    """Entry point when this script is run with agxViewer."""
    if application().getNumScenes() == 1:
        addRemainingScenes()
    buildFieldBowl()


init = init_app(
    name=__name__,
    scenes=[(buildScene, "1"), (buildMeshBowl, "2"), (buildFieldsOnShapes, "3"), (buildMixedPile, "4"), (buildWires, "5")],
    autoStepping=True,
)
