Renderable2d.java

package nl.tudelft.simulation.dsol.animation.d2;

import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.image.ImageObserver;

import javax.naming.NamingException;

import org.djutils.draw.Transform2d;
import org.djutils.draw.bounds.Bounds;
import org.djutils.draw.bounds.Bounds2d;
import org.djutils.draw.point.Point;
import org.djutils.draw.point.Point2d;
import org.djutils.logger.CategoryLogger;

import nl.tudelft.simulation.dsol.animation.Locatable;
import nl.tudelft.simulation.language.d2.Shape2d;
import nl.tudelft.simulation.naming.context.Contextualized;
import nl.tudelft.simulation.naming.context.util.ContextUtil;

/**
 * The Renderable2d provides an easy accessible renderable object that can be drawn on an absolute or relative position, scaled,
 * flipped, and rotated. For scaling, several options exist:<br>
 * - scale: whether to scale the drawing at all; e.g. for a legend, absolute coordinates might be used (scale = false);<br>
 * - scaleY: whether to scale differently in X and Y direction, e.g. for a map at higher latitudes (scaleY = true);<br>
 * - scaleObject: whether to scale the drawing larger or smaller than the scale factor of the extent (e.g., to draw an object on
 * a map where the units of the object are in meters, while the map is in lat / lon degrees).<br>
 * The default values are: translate = true; scale = true; flip = false; rotate = true; scaleY = false; scaleObject = false.
 * <p>
 * Copyright (c) 2002-2025 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See
 * for project information <a href="https://simulation.tudelft.nl/dsol/manual/" target="_blank">DSOL Manual</a>. The DSOL
 * project is distributed under a three-clause BSD-style license, which can be found at
 * <a href="https://simulation.tudelft.nl/dsol/docs/latest/license.html" target="_blank">DSOL License</a>.
 * </p>
 * @author <a href="https://www.linkedin.com/in/peterhmjacobs">Peter Jacobs </a>
 * @param <L> the Locatable class of the source that can return the location of the Renderable on the screen
 */
public abstract class Renderable2d<L extends Locatable> implements Renderable2dInterface<L>
{
    /**
     * Storage of the boolean flags, to prevent each flag from taking 32 bits... The initial value is binary 1011 = 0B: rotate =
     * true, flip = false, scale = true, translate = true, scaleY = false; scaleObject = false.
     */
    private byte flags = 0x0B;

    /** the source of the renderable. */
    private L source;

    /** the unique id of this animation object. */
    private int id;

    /**
     * Constructs a new Renderable2d.
     * @param source the source
     * @param contextProvider the object that can provide the context to store the animation objects
     */
    public Renderable2d(final L source, final Contextualized contextProvider)
    {
        this.source = source;
        this.bind2Context(contextProvider);
    }

    /**
     * Bind a renderable2D to the context. The reason for specifying this in an independent method instead of adding the code in
     * the constructor is related to the RFE submitted by van Houten that in specific distributed context, such binding must be
     * overwritten.
     * @param contextProvider the object that can provide the context to store the animation objects
     */
    public void bind2Context(final Contextualized contextProvider)
    {
        try
        {
            this.id = ANIMATION_OBJECT_COUNTER.incrementAndGet();
            ContextUtil.lookupOrCreateSubContext(contextProvider.getContext(), "animation/2D").bind(Integer.toString(this.id),
                    this);
        }
        catch (NamingException exception)
        {
            CategoryLogger.always().warn(exception);
        }
    }

    /**
     * Return whether to flip the renderable, if the direction is 'left' or not.
     * @return whether to flip the renderable, if the direction is 'left' or not
     */
    @Override
    public boolean isFlip()
    {
        return (this.flags & FLIP_FLAG) != 0;
    }

    /**
     * Set whether to flip the renderable, if the direction is 'left' or not.
     * @param flip whether to flip the renderable, if the direction is 'left' or not
     */
    @Override
    @SuppressWarnings("checkstyle:needbraces")
    public void setFlip(final boolean flip)
    {
        if (flip)
            this.flags |= FLIP_FLAG;
        else
            this.flags &= (~FLIP_FLAG);
    }

    /**
     * Return whether to rotate the renderable or not.
     * @return whether to rotate the renderable or not
     */
    @Override
    public boolean isRotate()
    {
        return (this.flags & ROTATE_FLAG) != 0;
    }

    /**
     * Set whether to rotate the renderable or not.
     * @param rotate whether to rotate the renderable or not
     */
    @Override
    @SuppressWarnings("checkstyle:needbraces")
    public void setRotate(final boolean rotate)
    {
        if (rotate)
            this.flags |= ROTATE_FLAG;
        else
            this.flags &= (~ROTATE_FLAG);
    }

    /**
     * Return whether to scale the renderable or not.
     * @return whether to scale the renderable or not
     */
    @Override
    public boolean isScale()
    {
        return (this.flags & SCALE_FLAG) != 0;
    }

    /**
     * Set whether to scale the renderable or not.
     * @param scale whether to scale the renderable or not
     */
    @Override
    @SuppressWarnings("checkstyle:needbraces")
    public void setScale(final boolean scale)
    {
        if (scale)
            this.flags |= SCALE_FLAG;
        else
            this.flags &= (~SCALE_FLAG);
    }

    /**
     * Return whether to scale the renderable in the Y-direction when there is a compressed Y-axis or not.
     * @return whether to scale the renderable in the Y-direction when there is a compressed Y-axis or not
     */
    @Override
    public boolean isScaleY()
    {
        return (this.flags & SCALE_Y_FLAG) != 0;
    }

    /**
     * Set whether to scale the renderable in the X/Y-direction with the value of RenderableScale.objectScaleFactor or not.
     * @param scaleY whether to scale the renderable in the X/Y-direction with the value of RenderableScale.objectScaleFactor or
     *            not
     */
    @Override
    @SuppressWarnings("checkstyle:needbraces")
    public void setScaleObject(final boolean scaleY)
    {
        if (scaleY)
            this.flags |= SCALE_OBJECT_FLAG;
        else
            this.flags &= (~SCALE_OBJECT_FLAG);
    }

    /**
     * Return whether to scale the renderable in the X/Y-direction with the value of RenderableScale.objectScaleFactor or not.
     * @return whether to scale the renderable in the X/Y-direction with the value of RenderableScale.objectScaleFactor or not
     */
    @Override
    public boolean isScaleObject()
    {
        return (this.flags & SCALE_OBJECT_FLAG) != 0;
    }

    /**
     * Set whether to scale the renderable in the Y-direction when there is a compressed Y-axis or not.
     * @param scaleY whether to scale the renderable in the Y-direction when there is a compressed Y-axis or not
     */
    @Override
    @SuppressWarnings("checkstyle:needbraces")
    public void setScaleY(final boolean scaleY)
    {
        if (scaleY)
            this.flags |= SCALE_Y_FLAG;
        else
            this.flags &= (~SCALE_Y_FLAG);
    }

    /**
     * Return whether to translate the renderable to its position or not (false means absolute position).
     * @return whether to translate the renderable to its position or not (false means absolute position)
     */
    @Override
    public boolean isTranslate()
    {
        return (this.flags & TRANSLATE_FLAG) != 0;
    }

    /**
     * Set whether to translate the renderable to its position or not (false means absolute position).
     * @param translate whether to translate the renderable to its position or not (false means absolute position)
     */
    @Override
    @SuppressWarnings("checkstyle:needbraces")
    public void setTranslate(final boolean translate)
    {
        if (translate)
            this.flags |= TRANSLATE_FLAG;
        else
            this.flags &= (~TRANSLATE_FLAG);
    }

    @Override
    public L getSource()
    {
        return this.source;
    }

    @Override
    public void paintComponent(final Graphics2D graphics, final Bounds2d extent, final Dimension screenSize,
            final RenderableScale renderableScale, final ImageObserver observer)
    {
        // by default: delegate to the paint() method.
        paint(graphics, extent, screenSize, renderableScale, observer);
    }

    /**
     * The methods that actually paints the object at the right scale, rotation, and position on the screen using the
     * user-implemented <code>paint(graphics, observer)</code> method to do the actual work.
     * @param graphics the graphics object
     * @param extent the extent of the panel
     * @param screenSize the screen of the panel
     * @param renderableScale the scale to use (usually RenderableScaleDefault where X/Y ratio is 1)
     * @param observer the observer of the renderableInterface
     */
    protected synchronized void paint(final Graphics2D graphics, final Bounds2d extent, final Dimension screenSize,
            final RenderableScale renderableScale, final ImageObserver observer)
    {
        if (this.source == null)
        {
            return;
        }
        // save the transform -- clone because transform is a volatile object
        AffineTransform transform = (AffineTransform) graphics.getTransform().clone();
        try
        {
            Point<?> center = this.source.getLocation();
            if (center == null)
            {
                return;
            }

            Bounds2d rectangle = BoundsUtil.projectBounds(center, this.source.getRelativeBounds());
            if (rectangle == null || (!Shape2d.overlaps(extent, rectangle) && isTranslate()))
            {
                return;
            }

            // Let's transform
            if (isTranslate())
            {
                Point2D screenCoordinates = renderableScale.getScreenCoordinates(center, extent, screenSize);
                graphics.translate(screenCoordinates.getX(), screenCoordinates.getY());
            }
            if (isScale())
            {
                double objectScaleFactor = isScaleObject() ? renderableScale.getObjectScaleFactor() : 1.0;
                if (isScaleY())
                {
                    graphics.scale(objectScaleFactor / renderableScale.getXScale(extent, screenSize),
                            objectScaleFactor / renderableScale.getYScale(extent, screenSize));
                }
                else
                {
                    graphics.scale(objectScaleFactor / renderableScale.getXScale(extent, screenSize), objectScaleFactor
                            * renderableScale.getYScaleRatio() / renderableScale.getYScale(extent, screenSize));
                }
            }
            double angle = -this.source.getDirZ();
            if (angle != 0.0)
            {
                if (isFlip() && angle < -Math.PI)
                {
                    angle = angle + Math.PI;
                }
                if (isRotate())
                {
                    graphics.rotate(angle);
                }
            }

            // Now we paint
            this.paint(graphics, observer);
        }
        catch (Exception exception)
        {
            CategoryLogger.always().warn(exception, "paint");
        }
        finally
        {
            // Let's untransform
            graphics.setTransform(transform);
        }
    }

    @Override
    public synchronized boolean contains(final Point2d pointWorldCoordinates, final Bounds2d extent)
    {
        if (pointWorldCoordinates == null || this.source == null || this.source.getLocation() == null)
        {
            return false;
        }
        Bounds2d intersect = BoundsUtil.projectBounds(this.source.getLocation(), this.source.getRelativeBounds());
        return intersect.contains(pointWorldCoordinates);
    }

    @Override
    public synchronized boolean contains(final Point2D pointScreenCoordinates, final Bounds2d extent,
            final Dimension screenSize, final RenderableScale scale, final double worldMargin, final double pixelMargin)
    {
        Point2d screenLocation = scale.getScreenCoordinatesAsPoint2d(getSource().getLocation(), extent, screenSize);
        Transform2d transformation = new Transform2d();
        transformation.reflectY();
        double xScale = scale.getXScale(extent, screenSize);
        double yScale = scale.getYScale(extent, screenSize);
        transformation.scale(xScale, yScale);
        transformation.rotation(getSource().getDirZ());
        transformation.translate(screenLocation.neg());
        Point2d pointRelativeTo00 =
                transformation.transform(new Point2d(pointScreenCoordinates.getX(), pointScreenCoordinates.getY()));
        return contains(pointRelativeTo00, scale, worldMargin, pixelMargin, xScale, yScale);
    }

    /**
     * Reference implementation of the contains method that uses the bounding box to determine whether the shape contains the
     * point (e.g., a mouse click) or not.
     * @param pointRelativeTo00 the point relative to the drawing world.
     * @param scale the current zoom factor of the screen
     * @param worldMargin the margin to apply 'around' the object, in screen coordinates at a zoom level of 1, which is the same
     *            as world coordinates. This margin grows and shrinks in absolute sense with the zoom factor.
     * @param pixelMargin the number of pixels around the drawn object for contains to be 'true'. This guarantees that a mouse
     *            click can be pointed to a very small object.
     * @param xScale the ratio between a world x-coordinate and a pixel
     * @param yScale the ratio between a world y-coordinate and a pixel
     * @return whether the point is in the shape or in a margin around the shape
     */
    public synchronized boolean contains(final Point2d pointRelativeTo00, final RenderableScale scale, final double worldMargin,
            final double pixelMargin, final double xScale, final double yScale)
    {
        Bounds<?, ?> b = getSource().getRelativeBounds();
        Bounds2d bounds = new Bounds2d(b.getMinX() * scale.getObjectScaleFactor(), b.getMaxX() * scale.getObjectScaleFactor(),
                b.getMinY() * scale.getObjectScaleFactor(), b.getMaxY() * scale.getObjectScaleFactor());
        double xMarginWorld = Math.max(worldMargin * scale.getObjectScaleFactor(), pixelMargin * xScale);
        double yMarginWorld = Math.max(worldMargin * scale.getObjectScaleFactor(), pixelMargin * yScale);
        Bounds2d marginBounds = new Bounds2d(bounds.getMinX() - xMarginWorld, bounds.getMaxX() + xMarginWorld,
                bounds.getMinY() - yMarginWorld, bounds.getMaxY() + yMarginWorld);
        return marginBounds.covers(pointRelativeTo00);
    }

    @Override
    public synchronized void destroy(final Contextualized contextProvider)
    {
        try
        {
            ContextUtil.lookupOrCreateSubContext(contextProvider.getContext(), "animation/2D")
                    .unbind(Integer.toString(this.id));
            this.source = null; // to indicate the animation is destroyed. Remove pointer to source for GC.
        }
        catch (NamingException exception)
        {
            CategoryLogger.always().warn(exception);
        }
    }

    @Override
    public int getId()
    {
        return this.id;
    }

    @Override
    public String toString()
    {
        return "Renderable2d [source=" + this.source + "]";
    }

    /**
     * Draws an animation on a world coordinate around [x,y] = [0,0].
     * @param graphics the graphics object
     * @param observer the observer
     */
    public abstract void paint(Graphics2D graphics, ImageObserver observer);

}