From 823aa63b57ee8b1c9c49a18b547bac05b648243c Mon Sep 17 00:00:00 2001 From: micycle1 Date: Sat, 6 Mar 2021 14:49:51 +0000 Subject: [PATCH] Implement output as lineMergeGraph and dissolved geometry --- README.md | 16 +- .../java/micycle/medialAxis/MedialAxis.java | 326 ++++++++++-------- 2 files changed, 200 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 82dfb46..47eed9f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # JMedialAxis -Medial Axes for JTS Geometry +Medial Axes (2D skeletons) for JTS Geometry ## Overview @@ -8,7 +8,6 @@ Medial Axes for JTS Geometry The library models the medial axis of a given geometry as a (rooted) tree of medial disks. The result is a medial axis that can be traversed recursively by starting at the largest disk and following the child nodes until they reach the boundary of the geometry. -Medial disks reference a parent and have up to 3 children (disks with more than one child represent bifurcation of the axis). The root of the tree is the disk whose underlying triangle has the largest circumcircle; this is also the single disk that trifurcates. The library is geared towards medial axis visualisation & animation and enables easy and powerful navigation of medial axes. @@ -17,16 +16,21 @@ by Roger C. Tam. ## Features -* JTS Geometry as input +* JTS geometry as input * For a given medial disk, easy access to its: * Descendants * Ancestors - * Belonging segment + * Belonging branch * Parent disk - * Children disks -* Branch/Segment pruning, via: + * Child disk(s) +* Branch pruning, via: * Feature area pruning * Supports geometries with holes (genus > 0) +* Output as: + * List of edges + * List of segments + * Dissolved (simplified) JTS geometry + * JTS LineMergeGraph ## Showcase ... diff --git a/src/main/java/micycle/medialAxis/MedialAxis.java b/src/main/java/micycle/medialAxis/MedialAxis.java index 57ee0da..483ea78 100644 --- a/src/main/java/micycle/medialAxis/MedialAxis.java +++ b/src/main/java/micycle/medialAxis/MedialAxis.java @@ -53,7 +53,7 @@ *

* Decompose the disk coordinates into curves. *

- * If angle of successive line segments is the same, merge them + * If angle of successive line branches is the same, merge them *

* * One way to get a smoother initial axis (i.e. without pruning) is to apply * on a smoothed version of the shape. @@ -67,20 +67,22 @@ public class MedialAxis { new PrecisionModel(PrecisionModel.FLOATING_SINGLE)); private Geometry g; -// private final PreparedGeometry cache; - private KDTree kdTree; -// private IncrementalTin tin; -// private TC tc; + private KDTree kdTree; + private Geometry dissolved; + private LineMergeGraph lineMergeGraph; + + private final ArrayList voronoiDisks = new ArrayList<>(); + public MedialDisk rootNode; // the medial axis has a trifuraction at the root + MedialDisk deepestNode; + private List leaves; + private List bifurcations; // birfurcating/forking nodes + private List branches; + private final List edges; - ArrayList voronoiDisks = new ArrayList<>(); - public VD rootNode; // the medial axis has a trifuraction at the root - VD deepestNode; - private List leaves; - private List bifurcations; // birfurcating/forking nodes - private List segments; + public boolean debug = false; -// public ArrayList> segments = new ArrayList<>(); // node sections between forks +// public ArrayList> branches = new ArrayList<>(); // node sections between forks public MedialAxis(Coordinate[] coordinates) { // TODO support holes @@ -108,7 +110,9 @@ public MedialAxis(Geometry g) { /** * Simplify if complex */ -// System.out.println("before " + g.getCoordinates().length); + if (debug) { + System.out.println("Input coordinates #: " + g.getCoordinates().length); + } if (g.getCoordinates().length > 2000) { Polygon poly = (Polygon) g; if (poly.getNumInteriorRing() > 0) { @@ -116,72 +120,83 @@ public MedialAxis(Geometry g) { } else { g = DouglasPeuckerSimplifier.simplify(g, 1); } + if (debug) { + System.out.println("Simplifying shape. New coordinates #: " + g.getCoordinates().length); + } } -// System.out.println("w/simplify " + g.getCoordinates().length); pointLocator = new IndexedPointInAreaLocator(g); this.g = Densifier.densify(g, 10); // NOTE constant=10 -// System.out.println("after " + this.g.getCoordinates().length); + if (debug) { + System.out.println("Densified coordinates #" + this.g.getCoordinates().length); + } TC tc = prepareTin(); + // TODO clustering after tin generation // having done triangulation, build up directed tree of medial axis - rootNode = new VD(null, 0, tc.largestDiskTriangle, tc.largestCircumcircle, 0); - deepestNode = rootNode; - ArrayDeque stack = new ArrayDeque<>(15); + rootNode = new MedialDisk(null, 0, tc.largestDiskTriangle, tc.largestCircumcircle, 0); + ArrayDeque stack = new ArrayDeque<>(15); stack.push(rootNode); // start tree with rootnode - HashSet remaining = new HashSet<>(); + HashSet remaining = new HashSet<>(); // use bit set? remaining.addAll(tc.triangles); remaining.remove(tc.largestDiskTriangle); + edges = new ArrayList(); + int depth = 1; // number of edges in the path from the root to the node int id = 1; // breadth-first ID while (!stack.isEmpty()) { - VD live = stack.pop(); // FIFO (BFS) + MedialDisk live = stack.pop(); // FIFO (BFS) voronoiDisks.add(live); - // get half-edge duals (shared with other triangles) + // get half-edge duals (edge shared with other triangles) final SimpleTriangle n1 = tc.map.get(live.t.getEdgeA().getDual()); final SimpleTriangle n2 = tc.map.get(live.t.getEdgeB().getDual()); final SimpleTriangle n3 = tc.map.get(live.t.getEdgeC().getDual()); if (remaining.contains(n1)) { - VD child = new VD(live, id++, n1, tc.cccMap.get(n1), depth); + MedialDisk child = new MedialDisk(live, id++, n1, tc.cccMap.get(n1), depth); stack.add(child); live.addchild(child); remaining.remove(n1); + edges.add(new Edge(live, child)); } if (remaining.contains(n2)) { - VD child = new VD(live, id++, n2, tc.cccMap.get(n2), depth); + MedialDisk child = new MedialDisk(live, id++, n2, tc.cccMap.get(n2), depth); stack.add(child); live.addchild(child); remaining.remove(n2); + edges.add(new Edge(live, child)); } if (remaining.contains(n3)) { - VD child = new VD(live, id++, n3, tc.cccMap.get(n3), depth); + MedialDisk child = new MedialDisk(live, id++, n3, tc.cccMap.get(n3), depth); stack.add(child); live.addchild(child); remaining.remove(n3); + edges.add(new Edge(live, child)); } depth++; } + deepestNode = voronoiDisks.get(voronoiDisks.size() - 1); // TODO check // calculateFeatureArea(); - calcFeatureArea(); -// System.out.println("total area: " + totalArea); -// System.out.println(rootNode.featureArea); +// calcFeatureArea(); + if (debug) { + System.out.println("Total Area: " + rootNode.featureArea); + } } - public List getDisks() { + public List getDisks() { return voronoiDisks; } - public VD nearestDisk(double x, double y) { - // TODO use cover tree? + public MedialDisk nearestDisk(double x, double y) { + // TODO use PhTree/Quadtree? if (kdTree == null) { // Lazy initialisation kdTree = KDTree.create(2); voronoiDisks.forEach(disk -> kdTree.insert(new double[] { disk.position.x, disk.position.y }, disk)); @@ -189,6 +204,10 @@ public VD nearestDisk(double x, double y) { return kdTree.nnQuery(new double[] { x, y }).value(); } + public MedialDisk nearestDisk(Coordinate coordinate) { + return nearestDisk(coordinate.x, coordinate.y); + } + /** * Returns the children of a disk into a linear array * @@ -196,15 +215,15 @@ public VD nearestDisk(double x, double y) { * position 0) * @return */ - public List getDescendants(VD parent) { + public List getDescendants(MedialDisk parent) { - ArrayDeque stack = new ArrayDeque<>(); + ArrayDeque stack = new ArrayDeque<>(); stack.add(parent); - ArrayList out = new ArrayList<>(); + ArrayList out = new ArrayList<>(); while (!stack.isEmpty()) { - VD live = stack.pop(); + MedialDisk live = stack.pop(); out.add(live); live.children.forEach(child -> { stack.add(child); @@ -213,16 +232,16 @@ public List getDescendants(VD parent) { return out; } - public List getDescendants(VD parent, int maxDepth) { + public List getDescendants(MedialDisk parent, int maxDepth) { - ArrayDeque stack = new ArrayDeque<>(); + ArrayDeque stack = new ArrayDeque<>(); stack.add(parent); - ArrayList out = new ArrayList<>(); + ArrayList out = new ArrayList<>(); int depth = 0; while (!stack.isEmpty() && depth < maxDepth) { - VD live = stack.pop(); + MedialDisk live = stack.pop(); out.add(live); live.children.forEach(child -> { stack.add(child); @@ -243,11 +262,11 @@ public List getDescendants(VD parent, int maxDepth) { * @param child * @return */ - public List getAncestors(VD child) { + public List getAncestors(MedialDisk child) { - ArrayList out = new ArrayList<>(); + ArrayList out = new ArrayList<>(); - VD live = child; + MedialDisk live = child; while (live.parent != null) { out.add(live); live = live.parent; @@ -255,13 +274,41 @@ public List getAncestors(VD child) { return out; } - public void getDissolvedGeometry() { - LineDissolver ld = new LineDissolver(); - // dissolved geom + /** + * Returns a JTS geometry where medial axis edges are dissolved into a set of + * maximal-length Linestrings. The output can be further simplfied with + * DouglasPeuckerSimplifier for example. + * + * @return + */ + public Geometry getDissolvedGeometry() { + if (dissolved == null) { // Lazy initialisation + LineDissolver ld = new LineDissolver(); + edges.forEach(e -> { + ld.add(e.lineString); + }); + dissolved = ld.getResult(); +// System.out.println(String.format("Dissolved %s axis edges into %s linestrings.", edges.size(), +// dissolved.getNumGeometries())); + } + return dissolved; } - public void getLineMergeGraph() { - LineMergeGraph lmGraph = new LineMergeGraph(); + /** + * Get the medial axis in the form of an undirected planar graph. The graph is + * based on the dissolved geometry. + * + * @return + */ + public LineMergeGraph getLineMergeGraph() { + if (lineMergeGraph == null) { // Lazy initialisation + getDissolvedGeometry(); + lineMergeGraph = new LineMergeGraph(); + for (int i = 0; i < dissolved.getNumGeometries(); i++) { + lineMergeGraph.addEdge((LineString) dissolved.getGeometryN(i)); + } + } + return lineMergeGraph; } /** @@ -269,7 +316,7 @@ public void getLineMergeGraph() { * * @return */ - public List getLeaves() { + public List getLeaves() { if (leaves == null) { // Lazy initialisation leaves = voronoiDisks.stream().filter(vd -> vd.degree == 0).collect(Collectors.toList()); } @@ -281,44 +328,49 @@ public List getLeaves() { * * @return */ - public List getBifurcations() { + public List getBifurcations() { if (bifurcations == null) { // Lazy initialisation bifurcations = voronoiDisks.stream().filter(vd -> vd.degree == 2).collect(Collectors.toList()); } return bifurcations; } - public void getEdges() { + /** + * + * @return list of edges (in breadth-first order from the root node) connecting + * medial disks. + */ + public List getEdges() { + return edges; } /** * Aka features, aka branches Segment is a linear portion of medial disks. * - * @return List of {@link micycle.medialAxis.MedialAxis.Segment segments} + * @return List of {@link micycle.medialAxis.MedialAxis.Branch branches} */ - public List getSegments() { - if (segments == null) { // Lazy initialisation - segments = new ArrayList<>(); + public List getBranches() { + if (branches == null) { // Lazy initialisation + branches = new ArrayList<>(); - Segment segment; - ArrayList forks = new ArrayList<>(getBifurcations()); + Branch branch; + ArrayList forks = new ArrayList<>(getBifurcations()); forks.add(rootNode); - for (VD disk : forks) { - for (VD child : disk.children) { - segment = new Segment(disk); // init segment with bifurcating node - VD node = child; + for (MedialDisk disk : forks) { + for (MedialDisk child : disk.children) { + branch = new Branch(disk); // init branch with bifurcating node + MedialDisk node = child; while (node.degree == 1) { - segment.add(node); + branch.add(node); node = node.children.get(0); // get the only child node } - segment.end(node); // end with axis leaf or next bifurcating node - segments.add(segment); + branch.end(node); // end with axis leaf or next bifurcating node + branches.add(branch); } } } - - return this.segments; + return this.branches; } /** @@ -333,7 +385,7 @@ private TC prepareTin() { // Triangulate geometry coordinates Coordinate[] coords = g.getCoordinates(); final ArrayList vertices = new ArrayList<>(); - for (int i = 0; i < coords.length; i++) { + for (int i = 0; i < coords.length - 1; i++) { vertices.add(new Vertex(coords[i].x, coords[i].y, 0)); } tin.add(vertices, null); // insert point set; points are triangulated upon insertion @@ -356,21 +408,21 @@ private void calculateDepthFirstIndices() { private void calculateFeatureArea() { /** - * Starting at leaves, sum each segment, stopping when reach bifurcating node; + * Starting at leaves, sum each branch, stopping when reach bifurcating node; * add bifurcation node to array and use that next time. */ - List nodes = getLeaves(); - HashSet nextLeaves = new HashSet<>(); // what to begin with next iteration - HashMap waitAtNodes = new HashMap<>(); + List nodes = getLeaves(); + HashSet nextLeaves = new HashSet<>(); // what to begin with next iteration + HashMap waitAtNodes = new HashMap<>(); getBifurcations().forEach(n -> waitAtNodes.put(n, 0)); while (!nodes.isEmpty()) { - for (VD node : nodes) { + for (MedialDisk node : nodes) { - VD current = node; + MedialDisk current = node; while (current.degree < 2) { // FIXME what happens when start with bifurcating node current.parent.featureArea += current.featureArea; @@ -400,7 +452,7 @@ private void calculateFeatureArea() { } } - nodes = new ArrayList(nextLeaves); + nodes = new ArrayList(nextLeaves); nextLeaves.clear(); } } @@ -411,11 +463,11 @@ private void calcFeatureArea() { } } - private static double recurseFeatureArea(VD node) { + private static double recurseFeatureArea(MedialDisk node) { if (node.degree == 0) { return node.area; } - for (VD child : node.children) { + for (MedialDisk child : node.children) { node.featureArea += recurseFeatureArea(child); } return node.featureArea; @@ -425,7 +477,7 @@ public void drawVDM(PApplet p) { // looks different when drawing with DFS vs BFS - ArrayDeque stack = new ArrayDeque<>(); + ArrayDeque stack = new ArrayDeque<>(); stack.push(rootNode); p.strokeWeight(5); p.colorMode(PConstants.HSB, 1, 1, 1, 1); @@ -435,7 +487,7 @@ public void drawVDM(PApplet p) { final float depth = deepestNode.depthBF; while (!stack.isEmpty()) { - VD live = stack.pop(); + MedialDisk live = stack.pop(); live.children.forEach(child -> { p.stroke((live.depthBF / depth) * .8f, 1, 1, 0.8f); @@ -456,7 +508,7 @@ public void drawVDM(PApplet p, int maxDepth) { // iterate by DFS for easier break - ArrayDeque stack = new ArrayDeque<>(); + ArrayDeque stack = new ArrayDeque<>(); stack.push(rootNode); p.strokeWeight(5); p.colorMode(PConstants.HSB, 1, 1, 1, 1); @@ -466,7 +518,7 @@ public void drawVDM(PApplet p, int maxDepth) { final float depth = deepestNode.depthBF; while (!stack.isEmpty()) { - VD live = stack.pop(); + MedialDisk live = stack.pop(); // Vertex v = live.t.getVertexA(); // p.beginShape(); @@ -522,7 +574,7 @@ public void drawVDMPrune(PApplet p, double threshold) { // iterate by DFS for easier break - ArrayDeque stack = new ArrayDeque<>(); + ArrayDeque stack = new ArrayDeque<>(); stack.push(rootNode); p.strokeWeight(5); p.colorMode(PConstants.HSB, 1, 1, 1, 1); @@ -532,7 +584,7 @@ public void drawVDMPrune(PApplet p, double threshold) { final float depth = deepestNode.depthBF; while (!stack.isEmpty()) { - VD live = stack.pop(); + MedialDisk live = stack.pop(); live.children.forEach(child -> { if (child.featureArea > areaLimit) { @@ -546,29 +598,6 @@ public void drawVDMPrune(PApplet p, double threshold) { p.colorMode(PConstants.RGB, 255, 255, 255, 255); } - /** - * @return double[3] of [x, y, radius] - */ - private static double[] circumcircle(Vertex a, Vertex b, Vertex c) { - - double D = (a.getX() - c.getX()) * (b.getY() - c.getY()) - (b.getX() - c.getX()) * (a.getY() - c.getY()); - double px = (((a.getX() - c.getX()) * (a.getX() + c.getX()) + (a.getY() - c.getY()) * (a.getY() + c.getY())) / 2 - * (b.getY() - c.getY()) - - ((b.getX() - c.getX()) * (b.getX() + c.getX()) + (b.getY() - c.getY()) * (b.getY() + c.getY())) / 2 - * (a.getY() - c.getY())) - / D; - - double py = (((b.getX() - c.getX()) * (b.getX() + c.getX()) + (b.getY() - c.getY()) * (b.getY() + c.getY())) / 2 - * (a.getX() - c.getX()) - - ((a.getX() - c.getX()) * (a.getX() + c.getX()) + (a.getY() - c.getY()) * (a.getY() + c.getY())) / 2 - * (b.getX() - c.getX())) - / D; - - double rs = (c.getX() - px) * (c.getX() - px) + (c.getY() - py) * (c.getY() - py); - - return new double[] { px, py, Math.sqrt(rs) }; - } - private class TC implements Consumer { SimpleTriangle largestDiskTriangle = null; // triangle with largest circumcircle @@ -629,17 +658,17 @@ public void accept(SimpleTriangle t) { * @author MCarleton * */ - public static class VD { + public static class MedialDisk { /** The underlying delaunay triangle associated with this disk */ public SimpleTriangle t; /** This disk's parent node. Null if root node */ - public VD parent; + public MedialDisk parent; /** * This disk's children nodes. Nodes have upto 3 children. Leaf nodes have 0 * children. */ - ArrayList children; + ArrayList children; public final int depthBF; // breadth-first depth from the root node, better for drawing. // TODO public int depthDF; // distance from the root, length of the path from n to the root TODO @@ -651,18 +680,18 @@ public static class VD { /** The sum of triangle areas of this disk and all its descendants */ public double featureArea; - VD segmentParent; + MedialDisk branchParent; // TODO -// "segment"/"path" b // segment is the set of VDs between two nodes of degree > 1, or between a node of degree >1 and a leaf - // segment should point to start and end +// "branch"/"path" b // branch is the set of VDs between two nodes of degree > 1, or between a node of degree >1 and a leaf + // branch should point to start and end - boolean forkChild = false; // is direct child of a bifurcating disk OR "issegmentParent" + boolean forkChild = false; // is direct child of a bifurcating disk OR "isbranchParent" boolean forkParent = false; // (is child a node of degree >1?) /** * Measures the change in the width of the shape per unit length of the axis.If * positive, this part of the object widens as we progress along the axis - * segment; if it’s negative the part narrows + * branch; if it’s negative the part narrows */ final double axialGradient; // axial gradient between this and its parent. (rChild-rParent/d) @@ -672,13 +701,12 @@ public static class VD { final int id; // BFS id from root node, unique for each disk (unlike depthDF) - VD(VD parent, int id, SimpleTriangle t, double[] circumcircle, int depthBF) { + MedialDisk(MedialDisk parent, int id, SimpleTriangle t, double[] circumcircle, int depthBF) { this.parent = parent; this.id = id; this.t = t; children = new ArrayList<>(3); this.depthBF = depthBF; -// this.circumcircle = circumcircle; area = t.getArea(); featureArea = area; position = new Coordinate(circumcircle[0], circumcircle[1]); @@ -693,7 +721,7 @@ public static class VD { /** * Add a child disk to this disk and increment its degree. */ - private void addchild(VD child) { + private void addchild(MedialDisk child) { children.add(child); degree++; } @@ -710,59 +738,58 @@ public int hashCode() { } /** - * Edges model links between two disks. Return graphs based on merged edges - * - * @author MCarleton - * + * An edge models a link between two disks. */ public static class Edge { - public final VD head; - public final VD tail; + public final MedialDisk head; + public final MedialDisk tail; int depth; - final double axialGradient; + public final double axialGradient; + public final LineString lineString; - public Edge(VD head, VD tail) { + public Edge(MedialDisk head, MedialDisk tail) { this.head = head; this.tail = tail; + lineString = GEOM_FACTORY.createLineString(new Coordinate[] { head.position, tail.position }); depth = head.depthBF; axialGradient = tail.axialGradient; } } /** - * segment/trunk segment + * branch/trunk branch * * comparable based on fork degree? * */ - public static class Segment { + public static class Branch { - public final VD root; // root disk - public VD leaf; // leaf disk + public final MedialDisk root; // root disk + public MedialDisk leaf; // leaf disk /** - * Disks between root and leaf of this segment, in descending order from the - * segment's root. + * Disks between root and leaf of this branch, in descending order from the + * branch's root. */ - public List innerDisks; - Segment sibling; // segment that shares parent disk + public List innerDisks; + Branch sibling; // branch that shares parent disk // contains bezier interpolation - int forkDegree; // how many forks are visited from the rootnode to the root of this segment + int forkDegree; // how many forks are visited from the rootnode to the root of this branch double length; // sum of edge lengths (not count) public final List edges; - public LineString lineString; + public LineString lineString; // TODO /** * * @param root a tri/bifurcating disk */ - Segment(VD root) { + Branch(MedialDisk root) { this.root = root; innerDisks = new ArrayList<>(); edges = new ArrayList(); } - void add(VD disk) { + void add(MedialDisk disk) { if (innerDisks.size() == 0) { edges.add(new Edge(root, disk)); } else { @@ -772,11 +799,11 @@ void add(VD disk) { } /** - * Add the last (leaf) disk for the segment + * Add the last (leaf) disk for the branch * * @param disk */ - void end(VD disk) { + void end(MedialDisk disk) { leaf = disk; if (innerDisks.isEmpty()) { // bifurcations that link directly to other bifucations edges.add(new Edge(root, disk)); @@ -795,6 +822,33 @@ void end(VD disk) { void smooth(int smoothingType) { // TODO } + + public boolean terminates() { + return root.isLeaf(); + } + } + + /** + * @return double[3] of [x, y, radius] + */ + private static double[] circumcircle(Vertex a, Vertex b, Vertex c) { + + double D = (a.getX() - c.getX()) * (b.getY() - c.getY()) - (b.getX() - c.getX()) * (a.getY() - c.getY()); + double px = (((a.getX() - c.getX()) * (a.getX() + c.getX()) + (a.getY() - c.getY()) * (a.getY() + c.getY())) / 2 + * (b.getY() - c.getY()) + - ((b.getX() - c.getX()) * (b.getX() + c.getX()) + (b.getY() - c.getY()) * (b.getY() + c.getY())) / 2 + * (a.getY() - c.getY())) + / D; + + double py = (((b.getX() - c.getX()) * (b.getX() + c.getX()) + (b.getY() - c.getY()) * (b.getY() + c.getY())) / 2 + * (a.getX() - c.getX()) + - ((a.getX() - c.getX()) * (a.getX() + c.getX()) + (a.getY() - c.getY()) * (a.getY() + c.getY())) / 2 + * (b.getX() - c.getX())) + / D; + + double rs = (c.getX() - px) * (c.getX() - px) + (c.getY() - py) * (c.getY() - py); + + return new double[] { px, py, Math.sqrt(rs) }; } private static double distance(Coordinate a, Coordinate b) {