/* * Copyright (C) 2009 Quadduc <quadduc@gmail.com> * * This file is part of LateralGM. * LateralGM is free software and comes with ABSOLUTELY NO WARRANTY. * See LICENSE for details. */ package org.lateralgm.components.visual; import static org.lateralgm.main.Util.negDiv; import java.awt.AWTEvent; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Point; import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.Stroke; import java.awt.Transparency; import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.Arrays; import java.util.EnumMap; import java.util.EnumSet; import java.util.IdentityHashMap; import java.util.Iterator; import java.util.Set; import javax.swing.SwingUtilities; import org.lateralgm.main.UpdateSource.UpdateEvent; import org.lateralgm.main.UpdateSource.UpdateListener; import org.lateralgm.resources.Path; import org.lateralgm.resources.ResourceReference; import org.lateralgm.resources.Room; import org.lateralgm.resources.Path.PPath; import org.lateralgm.resources.sub.PathPoint; import org.lateralgm.resources.sub.PathPoint.PPathPoint; import org.lateralgm.ui.swing.visuals.BinVisual; import org.lateralgm.ui.swing.visuals.GridVisual; import org.lateralgm.ui.swing.visuals.RoomVisual; import org.lateralgm.ui.swing.visuals.Visual; import org.lateralgm.ui.swing.visuals.VisualBox; import org.lateralgm.ui.swing.visuals.RoomVisual.Show; import org.lateralgm.util.ActiveArrayList; import org.lateralgm.util.PropertyMap; import org.lateralgm.util.ActiveArrayList.ListUpdateEvent; import org.lateralgm.util.PropertyMap.PropertyUpdateEvent; import org.lateralgm.util.PropertyMap.PropertyUpdateListener; import org.lateralgm.util.PropertyMap.PropertyValidator; public class PathEditor extends VisualPanel implements UpdateListener { private static final int ROOM_LAYER = -1; private static final int BIN_LAYER = 0; private static final int GRID_LAYER = 1; private static final int POINT_SIZE = 8; private static final int POINT_MOUSE_RANGE = 8; private static final int ARROW_SIZE = 10; private static final int LINE_WIDTH = 5; private static final long serialVersionUID = 1L; private final Path path; private RoomVisual roomVisual; private final BinVisual binVisual; private final GridVisual gridVisual; private final PathPropertyListener ppl = new PathPropertyListener(); private final PointListListener pll = new PointListListener(); public final PropertyMap<PPathEditor> properties; private final PathEditorPropertyValidator pepv = new PathEditorPropertyValidator(); public enum PPathEditor { SHOW_GRID,SELECTED_POINT } private static final EnumMap<PPathEditor,Object> DEFS = PropertyMap.makeDefaultMap( PPathEditor.class,true,null); private final ArrayList<PointVisual> pvList; private final IdentityHashMap<PathPoint,PointVisual> pvMap; private PathArrow arrow; public PathEditor(Path p) { enableEvents(AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK); properties = new PropertyMap<PPathEditor>(PPathEditor.class,pepv,DEFS); binVisual = new BinVisual(container,128,512,512); put(BIN_LAYER,binVisual); int sx = p.get(PPath.SNAP_X); int sy = p.get(PPath.SNAP_Y); gridVisual = new GridVisual(false,sx,sy); put(GRID_LAYER,gridVisual); path = p; path.reference.updateSource.addListener(this); path.properties.updateSource.addListener(ppl); path.points.updateSource.addListener(pll); int s = path.points.size(); pvList = new ArrayList<PointVisual>(s); pvMap = new IdentityHashMap<PathPoint,PointVisual>(Math.max((int) (s / .75f) + 1,16)); updatePointList(); ResourceReference<Room> r = path.get(PPath.BACKGROUND_ROOM); setRoom(r == null ? null : r.get()); } public void updated(UpdateEvent e) { invalidate(); repaint(); } private boolean dragging = false; private Point dragOffset; private PathPoint ppPressed; private void mouseEvent(MouseEvent e) { Point p = e.getPoint().getLocation(); componentToVisual(p); int s = POINT_MOUSE_RANGE - POINT_SIZE / 2 + 1; Iterator<Visual> vi = binVisual.intersect(new Rectangle(p.x - s,p.y - s,2 * s,2 * s)); PathPoint ppOver = null; int posd = POINT_MOUSE_RANGE * POINT_MOUSE_RANGE; while (vi.hasNext()) { Visual v = vi.next(); if (v instanceof PointVisual) { PathPoint pp = ((PointVisual) v).point; int xd = pp.getX() - p.x; int yd = pp.getY() - p.y; int sd = xd * xd + yd * yd; if (sd <= posd) { ppOver = pp; posd = sd; break; } } } switch (e.getID()) { case MouseEvent.MOUSE_PRESSED: ppPressed = ppOver; switch (e.getButton()) { case MouseEvent.BUTTON1: if (ppOver == null) { PathPoint pp = properties.get(PPathEditor.SELECTED_POINT); ppOver = new PathPoint(p.x,p.y,pp == null ? 100 : pp.getSpeed()); if ((e.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == 0) movePathPoint(ppOver,p.x,p.y,true); path.points.add(ppOver); ppPressed = ppOver; } else if ((e.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) != 0) { int i = path.points.indexOf(ppOver); ppOver = new PathPoint(ppOver.getX(),ppOver.getY(),ppOver.getSpeed()); path.points.add(i,ppOver); ppPressed = ppOver; } properties.put(PPathEditor.SELECTED_POINT,ppOver); dragging = true; dragOffset = p.getLocation(); dragOffset.translate(-ppOver.getX(),-ppOver.getY()); break; } break; case MouseEvent.MOUSE_DRAGGED: if (dragging) { lockBounds(); PathPoint pp = properties.get(PPathEditor.SELECTED_POINT); if (pp == null) { ppPressed = null; dragging = false; unlockBounds(); break; } movePathPoint(pp,p.x - dragOffset.x,p.y - dragOffset.y, (e.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == 0); } else if (ppPressed != ppOver) ppPressed = null; break; case MouseEvent.MOUSE_RELEASED: switch (e.getButton()) { case MouseEvent.BUTTON1: dragging = false; unlockBounds(); break; case MouseEvent.BUTTON3: if (dragging) { dragging = false; unlockBounds(); } else if (ppPressed != null) path.points.remove(ppPressed); break; } ppPressed = null; } } private void movePathPoint(PathPoint pp, int x, int y, boolean snap) { if (snap) { int sx = path.get(PPath.SNAP_X); int sy = path.get(PPath.SNAP_Y); pp.setX(negDiv(x + sx / 2,sx) * sx); pp.setY(negDiv(y + sy / 2,sy) * sy); } else { pp.setX(x); pp.setY(y); } } @Override protected void processMouseMotionEvent(MouseEvent e) { mouseEvent(e); super.processMouseMotionEvent(e); } @Override protected void processMouseEvent(MouseEvent e) { mouseEvent(e); super.processMouseEvent(e); } static final int HPS = POINT_SIZE >> 1; final BufferedImage[] pointImage = new BufferedImage[2]; private class PointVisual extends PathVisual { final PathPoint point; final Rectangle bounds = new Rectangle(POINT_SIZE,POINT_SIZE); final PointPositionListener ppl = new PointPositionListener(); private boolean selected; public PointVisual(PathPoint p) { point = p; binVisual.setDepth(this,0); p.properties.getUpdateSource(PPathPoint.X).addListener(ppl); p.properties.getUpdateSource(PPathPoint.Y).addListener(ppl); validate(); } public void setSelected(boolean s) { selected = s; binVisual.setDepth(this,s ? -2 : 0); } protected void calculateBounds() { bounds.setLocation(point.getX() - HPS,point.getY() - HPS); } protected void validate() { calculateBounds(); setBounds(bounds); } public void paint(Graphics g) { int i = selected ? 1 : 0; if (pointImage[i] == null) { pointImage[i] = getGraphicsConfiguration().createCompatibleImage(POINT_SIZE,POINT_SIZE, Transparency.BITMASK); Graphics2D g2 = pointImage[i].createGraphics(); g2.setColor(selected ? Color.RED : Color.BLUE); g2.fillOval(0,0,POINT_SIZE - 1,POINT_SIZE - 1); g2.setColor(Color.BLACK); g2.drawOval(0,0,POINT_SIZE - 1,POINT_SIZE - 1); } g.drawImage(pointImage[i],0,0,null); } private class PointPositionListener extends PropertyUpdateListener<PPathPoint> { @Override public void updated(PropertyUpdateEvent<PPathPoint> e) { invalidate(); } } } static final int HLW = LINE_WIDTH + 1 >> 1; static final Stroke STROKE_OUTER = new BasicStroke(LINE_WIDTH,BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); static final Stroke STROKE_INNER = new BasicStroke(LINE_WIDTH - 2.83f,BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); private int spPrecision; private float[] spBlending; private float getBlending(int p, int t) { if (p != spPrecision) { spPrecision = p; spBlending = new float[1 + 3 * p]; float isp = 1f / (p * p); float ip = 1f / p; for (int i = 0; i < p; i++) spBlending[i] = i * i * isp * 0.5f; for (int i = p; i < 2 * p; i++) spBlending[i] = (-2 * i * i * isp + 6 * i * ip - 3) * 0.5f; for (int i = 2 * p; i <= 3 * p; i++) spBlending[i] = (i * i * isp - 6 * i * ip + 9) * 0.5f; } return spBlending[t]; } private class LinearPathSegment extends PathVisual { final PathPoint[] pp = new PathPoint[2]; final Rectangle bounds = new Rectangle(); final PointPositionListener ppl = new PointPositionListener(); int px0, py0, px1, py1; public LinearPathSegment(PathPoint...p) { if (p.length != 2) throw new IllegalArgumentException(); System.arraycopy(p,0,pp,0,2); binVisual.setDepth(this,1); for (PathPoint point : p) { point.properties.getUpdateSource(PPathPoint.X).addListener(ppl); point.properties.getUpdateSource(PPathPoint.Y).addListener(ppl); } validate(); } protected void calculateBounds() { px0 = pp[0].getX(); py0 = pp[0].getY(); px1 = pp[1].getX(); py1 = pp[1].getY(); bounds.setBounds(0,0,-1,-1); bounds.add(px0,py0); bounds.add(px1,py1); bounds.grow(HLW,HLW); px0 -= bounds.x; py0 -= bounds.y; px1 -= bounds.x; py1 -= bounds.y; } @Override protected void validate() { calculateBounds(); setBounds(bounds); } public void paint(Graphics g) { Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(Color.BLACK); g2.setStroke(STROKE_OUTER); g2.drawLine(px0,py0,px1,py1); g2.setColor(Color.WHITE); g2.setStroke(STROKE_INNER); g2.drawLine(px0,py0,px1,py1); } private class PointPositionListener extends PropertyUpdateListener<PPathPoint> { @Override public void updated(PropertyUpdateEvent<PPathPoint> e) { invalidate(); } } } private class SmoothPathSegment extends PathVisual { final PathPoint[] pp = new PathPoint[4]; final Rectangle bounds = new Rectangle(); final PointPositionListener ppl = new PointPositionListener(); final InnerSegment innerSegment = new InnerSegment(); int[] px, py; public SmoothPathSegment(PathPoint...p) { if (p.length != 4) throw new IllegalArgumentException(); System.arraycopy(p,0,pp,0,4); binVisual.setDepth(this,2); for (PathPoint point : p) { if (point == null) continue; point.properties.getUpdateSource(PPathPoint.X).addListener(ppl); point.properties.getUpdateSource(PPathPoint.Y).addListener(ppl); } validate(); } @Override public void remove() { innerSegment.remove(); super.remove(); } private void pBlend(int i, int n, int t, int[] x, int[] y) { float w0 = getBlending(n,t + 1 + 2 * n); float w2 = getBlending(n,t + 1); px[i] = x[1] + Math.round(w0 * (x[0] - x[1]) + w2 * (x[2] - x[1])); py[i] = y[1] + Math.round(w0 * (y[0] - y[1]) + w2 * (y[2] - y[1])); } protected void calculateBounds() { bounds.setBounds(0,0,-1,-1); int[] x = new int[4]; int[] y = new int[4]; for (int i = 0; i < 4; i++) { if (pp[i] == null) continue; x[i] = pp[i].getX(); y[i] = pp[i].getY(); } int p = path.properties.get(PPath.PRECISION); int n = (1 << p); if (pp[0] == null) { px = new int[2]; py = new int[2]; px[0] = x[1]; py[0] = y[1]; bounds.add(x[1],y[1]); pBlend(1,n,0,Arrays.copyOfRange(x,1,4),Arrays.copyOfRange(y,1,4)); bounds.add(px[1],py[1]); } else { px = new int[n]; py = new int[n]; for (int t = 0; t < n - 1; t++) { pBlend(t,n,t,x,y); bounds.add(px[t],py[t]); } if (pp[3] == null) { px[n - 1] = x[2]; py[n - 1] = y[2]; } else pBlend(n - 1,n,0,Arrays.copyOfRange(x,1,4),Arrays.copyOfRange(y,1,4)); bounds.add(px[n - 1],py[n - 1]); } bounds.grow(HLW,HLW); for (int t = 0; t < px.length; t++) { px[t] -= bounds.x; py[t] -= bounds.y; } } @Override protected void validate() { calculateBounds(); setBounds(bounds); innerSegment.setBounds(bounds); } public void paint(Graphics g) { Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(Color.BLACK); g2.setStroke(STROKE_OUTER); g2.drawPolyline(px,py,px.length); } private class InnerSegment extends VisualBox { public InnerSegment() { super(binVisual); binVisual.setDepth(this,1); } @Override protected void setBounds(Rectangle b) { super.setBounds(b); } public void paint(Graphics g) { Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(Color.WHITE); g2.setStroke(STROKE_INNER); g2.drawPolyline(px,py,px.length); } } private class PointPositionListener extends PropertyUpdateListener<PPathPoint> { @Override public void updated(PropertyUpdateEvent<PPathPoint> e) { invalidate(); } } } private static final double ARROW_ANGLE = 2 * Math.PI / 3; private class PathArrow extends PathVisual { final PointPositionListener ppl = new PointPositionListener(); final SmoothPathSegment segment; final int[] px = new int[4]; final int[] py = new int[4]; public PathArrow(SmoothPathSegment s) { segment = s; binVisual.setDepth(this,-1); int i2 = s == null ? 2 : path.get(PPath.CLOSED) ? 4 : 3; for (int i = 0; i < i2; i++) { PathPoint p = path.points.get(i == 3 ? path.points.size() - 1 : i); p.properties.getUpdateSource(PPathPoint.X).addListener(ppl); p.properties.getUpdateSource(PPathPoint.Y).addListener(ppl); } validate(); } private void calculatePoints(int x, int y, double d) { px[0] = x + (int) Math.round(ARROW_SIZE * Math.cos(d)); py[0] = y - (int) Math.round(ARROW_SIZE * Math.sin(d)); px[1] = x + (int) Math.round(ARROW_SIZE * Math.cos(d + ARROW_ANGLE)); py[1] = y - (int) Math.round(ARROW_SIZE * Math.sin(d + ARROW_ANGLE)); px[2] = x; py[2] = y; px[3] = x + (int) Math.round(ARROW_SIZE * Math.cos(d - ARROW_ANGLE)); py[3] = y - (int) Math.round(ARROW_SIZE * Math.sin(d - ARROW_ANGLE)); } private int sqrdist(int x, int y) { return x * x + y * y; } @Override protected void validate() { if (segment == null) { PathPoint p = path.points.get(0); PathPoint p2 = path.points.get(1); int x = p.getX(); int y = p.getY(); calculatePoints(x,y,Math.atan2(y - p2.getY(),p2.getX() - x)); } else { segment.validate(); if (path.get(PPath.CLOSED)) { int i = segment.px.length - 1; int x = segment.px[i]; int y = segment.py[i]; int i2 = i - 1; while (i2 > 0 && sqrdist(segment.px[i2] - x,segment.py[i2] - y) < 4) i2--; calculatePoints(x + segment.bounds.x,y + segment.bounds.y, Math.atan2(segment.py[i2] - y,x - segment.px[i2])); } else { int x = segment.px[0]; int y = segment.py[0]; int i = 1; while (i < segment.px.length - 1 && sqrdist(segment.px[i] - x,segment.py[i] - y) < 4) i++; calculatePoints(x + segment.bounds.x,y + segment.bounds.y, Math.atan2(y - segment.py[i],segment.px[i] - x)); } } Rectangle bounds = new Rectangle(-1,-1); for (int i = 0; i < 4; i++) bounds.add(px[i],py[i]); for (int i = 0; i < 4; i++) { px[i] -= bounds.x; py[i] -= bounds.y; } setBounds(bounds); } public void paint(Graphics g) { Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(new Color(0,224,0)); g2.fillPolygon(px,py,4); g2.setColor(Color.BLACK); g2.drawPolygon(px,py,4); } private class PointPositionListener extends PropertyUpdateListener<PPathPoint> { @Override public void updated(PropertyUpdateEvent<PPathPoint> e) { invalidate(); } } } private abstract class PathVisual extends VisualBox { private boolean invalid; public PathVisual() { super(binVisual); } protected abstract void validate(); protected final void invalidate() { if (invalid) return; invalid = true; SwingUtilities.invokeLater(new Runnable() { public void run() { try { if (invalid) validate(); } finally { invalid = false; } } }); } } final IdentityHashMap<PathPoint,SmoothPathSegment> spsMap = new IdentityHashMap<PathPoint,SmoothPathSegment>(); final IdentityHashMap<PathPoint,LinearPathSegment> lpsMap = new IdentityHashMap<PathPoint,LinearPathSegment>(); @SuppressWarnings("unchecked") private void updatePointList() { for (SmoothPathSegment sps : spsMap.values()) sps.remove(); spsMap.clear(); for (LinearPathSegment lps : lpsMap.values()) lps.remove(); lpsMap.clear(); if (arrow != null) arrow.remove(); Set<PathPoint> pps = ((IdentityHashMap<PathPoint,PointVisual>) pvMap.clone()).keySet(); int s = pvList.size(); ActiveArrayList<PathPoint> pp = path.points; int s2 = pp.size(); while (s > s2) pvList.remove(--s); pvList.ensureCapacity(s2); for (int i = 0; i < s2; i++) { PathPoint p = pp.get(i); PointVisual v = pvMap.get(p); if (v == null) { v = new PointVisual(p); pvMap.put(p,v); } else { pps.remove(p); } if (i >= s) pvList.add(v); else pvList.set(i,v); } if (pps.contains(properties.get(PPathEditor.SELECTED_POINT))) properties.put(PPathEditor.SELECTED_POINT,null); for (PathPoint pathPoint : pps) pvMap.remove(pathPoint).remove(); if (path.get(PPath.SMOOTH)) { boolean closed = path.properties.get(PPath.CLOSED); if (s2 >= 3) { PathPoint[] rpp = new PathPoint[4]; for (int i = 0; i < 4; i++) rpp[i] = pp.get(i % s2); int i2 = closed ? s2 + 1 : s2 - 2; for (int i = 1; i < i2; i++) { PathPoint p = rpp[1]; spsMap.put(p,new SmoothPathSegment(rpp)); rpp = Arrays.copyOfRange(rpp,1,5); rpp[3] = pp.get((i + 3) % s2); } if (!closed) { PathPoint p = pp.get(0); spsMap.put(p,new SmoothPathSegment(null,p,pp.get(1),pp.get(2))); p = pp.get(s2 - 2); spsMap.put(p,new SmoothPathSegment(pp.get(s2 - 3),p,pp.get(s2 - 1),null)); } arrow = new PathArrow(spsMap.get(pp.get(0))); } } else { if (s2 >= 2) { for (int i = 0; i < s2 - 1; i++) { PathPoint p = path.points.get(i); lpsMap.put(p,new LinearPathSegment(p,path.points.get(i + 1))); } if (path.properties.get(PPath.CLOSED)) { PathPoint p = path.points.get(s2 - 1); lpsMap.put(p,new LinearPathSegment(p,path.points.get(0))); } arrow = new PathArrow(null); } } } private static final EnumSet<Show> ROOM_SHOW = EnumSet.of(Show.BACKGROUNDS,Show.INSTANCES, Show.TILES,Show.FOREGROUNDS); private void setRoom(Room r) { if (roomVisual == null ? r == null : roomVisual.room == r) return; roomVisual = r == null ? null : new RoomVisual(container,r,ROOM_SHOW); put(ROOM_LAYER,roomVisual); } private class PathPropertyListener extends PropertyUpdateListener<PPath> { @Override public void updated(PropertyUpdateEvent<PPath> e) { switch (e.key) { case SNAP_X: gridVisual.setWidth((Integer) path.get(PPath.SNAP_X)); repaint(); break; case SNAP_Y: gridVisual.setHeight((Integer) path.get(PPath.SNAP_Y)); repaint(); break; case PRECISION: for (SmoothPathSegment s : spsMap.values()) s.validate(); arrow.validate(); break; case CLOSED: case SMOOTH: // TODO: Optimize updatePointList(); break; case BACKGROUND_ROOM: ResourceReference<Room> r = path.get(PPath.BACKGROUND_ROOM); setRoom(r == null ? null : r.get()); } } } private class PointListListener implements UpdateListener { public void updated(UpdateEvent e) { ListUpdateEvent lue = (ListUpdateEvent) e; switch (lue.type) { case ADDED: // for (int i = lue.fromIndex; i <= lue.toIndex; i++) // { // PathPoint p = path.points.get(i); // PointVisual v = new PointVisual(p); // pvMap.put(p,v); // pvList.add(i,v); // } // break; case REMOVED: // for (int i = lue.toIndex; i <= lue.fromIndex; i++) // { // PathPoint p = path.points.get(i); // pvMap.remove(p); // pvList.remove(i).box.remove(); // } // break; case CHANGED: updatePointList(); } } } private class PathEditorPropertyValidator implements PropertyValidator<PPathEditor> { public Object validate(PPathEditor k, Object v) { switch (k) { case SELECTED_POINT: PointVisual pv = pvMap.get(properties.get(k)); if (pv != null) { if (v == pv.point) break; pv.setSelected(false); } pv = pvMap.get(v); if (pv == null) { if (pvList.size() < 1) return null; pv = pvList.get(0); } pv.setSelected(true); return pv.point; case SHOW_GRID: if (v instanceof Boolean) put(GRID_LAYER,(Boolean) v ? gridVisual : null); else return properties.get(k); break; } return v; } } }