/** * OrbisGIS is a java GIS application dedicated to research in GIScience. * OrbisGIS is developed by the GIS group of the DECIDE team of the * Lab-STICC CNRS laboratory, see <http://www.lab-sticc.fr/>. * * The GIS group of the DECIDE team is located at : * * Laboratoire Lab-STICC – CNRS UMR 6285 * Equipe DECIDE * UNIVERSITÉ DE BRETAGNE-SUD * Institut Universitaire de Technologie de Vannes * 8, Rue Montaigne - BP 561 56017 Vannes Cedex * * OrbisGIS is distributed under GPL 3 license. * * Copyright (C) 2007-2014 CNRS (IRSTV FR CNRS 2488) * Copyright (C) 2015-2017 CNRS (Lab-STICC UMR CNRS 6285) * * This file is part of OrbisGIS. * * OrbisGIS is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any later * version. * * OrbisGIS is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with * OrbisGIS. If not, see <http://www.gnu.org/licenses/>. * * For more information, please consult: <http://www.orbisgis.org/> * or contact directly: * info_at_ orbisgis.org */ package org.orbisgis.coremap.renderer.se.stroke; import java.awt.*; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import javax.xml.bind.JAXBElement; import net.opengis.se._2_0.core.ObjectFactory; import net.opengis.se._2_0.core.ParameterValueType; import net.opengis.se._2_0.core.PenStrokeType; import org.orbisgis.coremap.map.MapTransform; import org.orbisgis.coremap.renderer.se.FillNode; import org.orbisgis.coremap.renderer.se.SeExceptions.InvalidStyle; import org.orbisgis.coremap.renderer.se.SymbolizerNode; import org.orbisgis.coremap.renderer.se.common.ShapeHelper; import org.orbisgis.coremap.renderer.se.common.Uom; import org.orbisgis.coremap.renderer.se.fill.Fill; import org.orbisgis.coremap.renderer.se.fill.SolidFill; import org.orbisgis.coremap.renderer.se.parameter.ParameterException; import org.orbisgis.coremap.renderer.se.parameter.SeParameterFactory; import org.orbisgis.coremap.renderer.se.parameter.real.RealLiteral; import org.orbisgis.coremap.renderer.se.parameter.real.RealParameter; import org.orbisgis.coremap.renderer.se.parameter.real.RealParameterContext; import org.orbisgis.coremap.renderer.se.parameter.string.StringLiteral; import org.orbisgis.coremap.renderer.se.parameter.string.StringParameter; import org.xnap.commons.i18n.I18n; import org.xnap.commons.i18n.I18nFactory; /** * Basic stroke for linear features. It is designed according to : * <ul><li>A {@link Fill} value</li> * <li>A width</li> * <li>A way to draw the extremities of the lines</li> * <li>A way to draw the joins between the segments of the lines</li> * <li>An array of dashes, that is used to draw the lines. The array is stored as a StringParamater, * that contains space separated double values. This double values are used to determine * the length of each opaque part (even elements of the array) and the length of * each transparent part (odd elements of the array). If an odd number of values is given, * the pattern is expanded by repeating it twice to give an even number of values.</li> * <li>An offset used to know where to draw the line.</li> * </ul> * @author Maxence Laurent, Alexis Guéganno */ public final class PenStroke extends Stroke implements FillNode { private static final I18n I18N = I18nFactory.getI18n(PenStroke.class, Locale.getDefault(), I18nFactory.FALLBACK); private static final double DEFAULT_WIDTH_PX = 1.0; public static final double DEFAULT_WIDTH = .25; /** * The cap used by default. Value is {@code LineCap.BUTT}. */ public static final LineCap DEFAULT_CAP = LineCap.BUTT; /** * The join used by default. Value is {@code LineCap.MITRE}. */ public static final LineJoin DEFAULT_JOIN = LineJoin.MITRE; private Fill fill; private RealParameter width; private LineJoin lineJoin; private LineCap lineCap; private StringParameter dashArray; private RealParameter dashOffset; /** * There are three ways to draw the end of a line : butt, round and square. */ public enum LineCap { BUTT, ROUND, SQUARE; /** * Build a {@link ParameterValueType} from this {@code LineCap}. * @return This LineCap in a ParameterValueType. */ public ParameterValueType getParameterValueType() { return SeParameterFactory.createParameterValueType(this.name().toLowerCase()); } } /** * There are three ways to join the segments of a LineString : mitre, round, bevel. */ public enum LineJoin { MITRE, ROUND, BEVEL; /** * Build a {@link ParameterValueType} from this {@code LineJoin}. * @return This LineJoin in a ParameterValueType. */ public ParameterValueType getParameterValueType() { return SeParameterFactory.createParameterValueType(this.name().toLowerCase()); } } /** * Create a standard 0.1mm-wide opaque black stroke without dash. */ public PenStroke() { super(); setFill(getDefaultFill()); setWidth(new RealLiteral(DEFAULT_WIDTH)); setUom(null); setDashArray(new StringLiteral("")); setDashOffset(new RealLiteral(0)); setLineCap(DEFAULT_CAP); setLineJoin(DEFAULT_JOIN); } /** * Build a PenStroke from the JaXB type given in argument. * @param t The input JaXB element */ public PenStroke(PenStrokeType t) throws InvalidStyle { super(t); if (t.getUom() != null) { setUom(Uom.fromOgcURN(t.getUom())); } if (t.getFill() != null) { this.setFill(Fill.createFromJAXBElement(t.getFill())); } else { this.setFill(new SolidFill(Color.BLACK,1.0)); } //Null values are handled by the setter and resent by SeParameterFactory this.setDashArray(SeParameterFactory.createStringParameter(t.getDashArray())); if (t.getDashOffset() != null) { this.setDashOffset(SeParameterFactory.createRealParameter(t.getDashOffset())); } if (t.getWidth() != null) { this.setWidth(SeParameterFactory.createRealParameter(t.getWidth())); } else { setWidth(new RealLiteral(DEFAULT_WIDTH)); } if (t.getLineCap() != null) { try { StringParameter lCap = SeParameterFactory.createStringParameter(t.getLineCap()); this.setLineCap(LineCap.valueOf(lCap.getValue(null, -1).toUpperCase())); } catch (Exception ex) { Logger.getLogger(PenStroke.class.getName()).log(Level.SEVERE, "Could not convert line cap", ex); } } if (t.getLineJoin() != null) { try { StringParameter lJoin = SeParameterFactory.createStringParameter(t.getLineJoin()); this.setLineJoin(LineJoin.valueOf(lJoin.getValue(null, -1).toUpperCase())); } catch (Exception ex) { Logger.getLogger(PenStroke.class.getName()).log(Level.SEVERE, "Could not convert line join", ex); } } } /** * Build a {@code PenStroke} from the JAXBElement given in argument. * @param s The input JaXB element. * @throws org.orbisgis.coremap.renderer.se.SeExceptions.InvalidStyle */ public PenStroke(JAXBElement<PenStrokeType> s) throws InvalidStyle { this(s.getValue()); } @Override public Double getNaturalLength(Map<String,Object> map, Shape shp, MapTransform mt) { if (dashArray != null) { // A dashed PenStroke has a length // This is required to compute hatches tile but will break the compound stroke natural length logic // for infinite PenStroke element ! For this reason, compound stroke use getNaturalLengthForCompound try { double sum = 0.0; String sDash = this.dashArray.getValue(map); if(!sDash.isEmpty()){ String[] splitDash = sDash.split(" "); int size = splitDash.length; for (int i = 0; i < size; i++) { sum += Uom.toPixel(Double.parseDouble(splitDash[i]), getUom(), mt.getDpi(), mt.getScaleDenominator(), null); } if (size % 2 == 1) { // # pattern item is odd -> 2* to close the pattern sum *= 2; } return sum; } } catch (ParameterException ex) { return Double.POSITIVE_INFINITY; } } return Double.POSITIVE_INFINITY; } @Override public Double getNaturalLengthForCompound(Map<String,Object> map, Shape shp, MapTransform mt) throws ParameterException, IOException { return Double.POSITIVE_INFINITY; } @Override public List<SymbolizerNode> getChildren() { List<SymbolizerNode> ls = new ArrayList<SymbolizerNode>(); if (fill != null) { ls.add(fill); } if (dashOffset != null) { ls.add(dashOffset); } if (dashArray != null) { ls.add(dashArray); } if (width != null) { ls.add(width); } return ls; } @Override public Fill getFill() { return fill; } /** * Sets the fill used to draw the inside of this {@code PenStroke}. * @param fill The new {@link Fill}. If null, will be set to a {@link SolidFill} which color is black and opacity * is 100%. */ @Override public void setFill(Fill fill) { this.fill = fill == null ? getDefaultFill() : fill; this.fill.setParent(this); } private Fill getDefaultFill(){ return new SolidFill(Color.BLACK, 1.0); } /** * Sets the way to draw the extremities of a line. * @param cap The new {@link LineCap}. Will be replaced by {@see DEFAULT_CAP} if null. */ public void setLineCap(LineCap cap) { lineCap = cap == null ? DEFAULT_CAP : cap; } /** * Gets the way used to draw the extremities of a line. * @return */ public LineCap getLineCap() { if (lineCap != null) { return lineCap; } else { return DEFAULT_CAP; } } /** * Sets the ways used to draw the join between line segments. * @param join The new {@link LineJoin}. Will be replaced by {@see DEFAULT_JOIN} if null. */ public void setLineJoin(LineJoin join) { lineJoin = join == null ? DEFAULT_JOIN : join; } /** * Gets the ways used to draw the join between line segments. * @return */ public LineJoin getLineJoin() { if (lineJoin != null) { return lineJoin; } else { return DEFAULT_JOIN; } } /** * Set the width used to draw the lines with this {@code PenStroke}. * @param width The new width. If null, will be replaced with {@link PenStroke#DEFAULT_WIDTH}, as specified in SE 2.0. */ public void setWidth(RealParameter width) { this.width = width == null ? new RealLiteral(DEFAULT_WIDTH) : width; if (width != null) { width.setContext(RealParameterContext.NON_NEGATIVE_CONTEXT); width.setParent(this); } } /** * Gets the width used to draw the lines with this PenStroke. * @return */ public RealParameter getWidth() { return this.width; } /** * Gets the offset let before drawing the first dash. * @return The offset let before drawing the first dash. */ public RealParameter getDashOffset() { return dashOffset; } /** * Sets the offset let before drawing the first dash. * @param dashOffset If null, will be defaulted to 0. */ public void setDashOffset(RealParameter dashOffset) { this.dashOffset = dashOffset == null ? new RealLiteral(0) : dashOffset; this.dashOffset.setContext(RealParameterContext.REAL_CONTEXT); this.dashOffset.setParent(this); } /** * Gets the array of double values that will be used to draw a dashed line. This "array" * is in fact stored as a string parameter, filled with space separated double values.</p> * <p>These values represent the length (in the inner UOM) of the opaque (even elements of the array) * and transparent (odd elements of the array) parts of the lines to draw. * @return */ public StringParameter getDashArray() { return dashArray; } /** * Sets the array of double values that will be used to draw a dashed line. This "array" * is in fact stored as a string parameter, filled with space separated double values.</p> * <p>These values represent the length (in the inner UOM) of the opaque (even elements of the array) * and transparent (odd elements of the array) parts of the lines to draw. * @param dashArray The new dash array. If null, will be replaced by a StringLiteral built with the empty string. */ public void setDashArray(StringParameter dashArray) { this.dashArray = dashArray == null ? new StringLiteral("") : dashArray; this.dashArray.setParent(this); } private BasicStroke createBasicStroke(Map<String,Object> map, Shape shp, MapTransform mt, Double v100p, boolean useDash) throws ParameterException { int cap; if (this.lineCap == null) { cap = BasicStroke.CAP_BUTT; } else { switch (this.lineCap) { case ROUND: cap = BasicStroke.CAP_ROUND; break; case SQUARE: cap = BasicStroke.CAP_SQUARE; break; default: case BUTT: cap = BasicStroke.CAP_BUTT; break; } } int join; if (this.lineJoin == null) { join = BasicStroke.JOIN_ROUND; } else { switch (this.lineJoin) { case BEVEL: join = BasicStroke.JOIN_BEVEL; break; case MITRE: join = BasicStroke.JOIN_MITER; break; case ROUND: default: join = BasicStroke.JOIN_ROUND; break; } } double w = DEFAULT_WIDTH_PX; if (width != null) { w = width.getValue(map); w = Uom.toPixel(w, getUom(), mt.getDpi(), mt.getScaleDenominator(), null); // 100% based on view box height or width ? TODO } if (useDash && this.dashArray != null && !this.dashArray.getValue(map).isEmpty()) { double dashO = 0.0; double[] dashA; String sDash = this.dashArray.getValue(map); String[] splitedDash = sDash.split(" "); int dashSize = splitedDash.length; dashA = new double[dashSize]; for (int i = 0; i < dashSize; i++) { dashA[i] = Uom.toPixel(Double.parseDouble(splitedDash[i]), getUom(), mt.getDpi(), mt.getScaleDenominator(), v100p); } if (this.dashOffset != null) { dashO = Uom.toPixel(this.dashOffset.getValue(map), getUom(), mt.getDpi(), mt.getScaleDenominator(), v100p); } if (this.isLengthRapport()) { scaleDashArrayLength(dashA, shp); } float[] dashes = new float[dashA.length]; int dashesSize = dashes.length; for (int i = 0; i < dashesSize; i++) { dashes[i] = (float) dashA[i]; if(dashes[i] < 0){ throw new IllegalArgumentException(I18N.tr("Dash array must be made " + "of positive numbers separated with spaces.")); } } return new BasicStroke((float) w, cap, join, 10.0f, dashes, (float) dashO); } else { return new BasicStroke((float) w, cap, join); } } /** * Get an AWT {@code BasicStroke} that is representative of this {@code * PenStroke} * @param map * @param mt * @param v100p * @return * @throws ParameterException * @throws IllegalArgumentException If the embedded dash pattern is invalid * (eg. if it contains negative numbers). */ public BasicStroke getBasicStroke(Map<String,Object> map, MapTransform mt, Double v100p) throws ParameterException { return this.createBasicStroke(map, null, mt, v100p, true); } private void scaleDashArrayLength(double[] dashes, Shape shp) { if (shp == null) { return; } double lineLength = ShapeHelper.getLineLength(shp); double sum = 0.0; for (double dash : dashes) { sum += dash; } int dashesSize = dashes.length; // number of element is odd => x2 if ((dashesSize % 2) == 1) { sum *= 2; } double nbPattern = (int) ((lineLength / sum)); if (nbPattern > 0) { double f = lineLength / (sum * nbPattern); for (int i = 0; i < dashesSize; i++) { dashes[i] *= f; } } } /** * Draw a pen stroke, using the given Graphics2D. * * @todo DashOffset * @param g2 * @param map * @param shape * @param selected * @param mt * @param offset * @throws ParameterException * @throws IOException * @throws IllegalArgumentException If the embedded dash pattern is invalid * (eg. if it contains negative numbers). */ @Override public void draw(Graphics2D g2, Map<String,Object> map, Shape shape, boolean selected, MapTransform mt, double offset) throws ParameterException, IOException { if (this.fill != null && width.getValue(map) > 0) { List<Shape> shapes; // if not using offset rapport, compute perpendicular offset first if (!this.isOffsetRapport() && Math.abs(offset) > 0.0) { shapes = ShapeHelper.perpendicularOffset(shape, offset); // Setting offset to 0.0 let be sure the offset will never been applied twice! offset = 0.0; } else { shapes = new ArrayList<Shape>(); shapes.add(shape); } Paint paint = fill.getPaint(map, selected, mt); for (Shape shp : shapes) { if (this.dashArray != null && !this.dashArray.getValue(map).isEmpty() && Math.abs(offset) > 0.0) { String value = dashArray.getValue(map); String[] split = value.split("\\s+"); Shape chute = shp; List<Shape> fragments = new ArrayList<Shape>(); BasicStroke bs = createBasicStroke(map, shp, mt, null, false); int splitSize = split.length; double dashLengths[] = new double[splitSize]; for (int i = 0; i < splitSize; i++) { dashLengths[i] = Uom.toPixel(Double.parseDouble(split[i]), getUom(), mt.getDpi(), mt.getScaleDenominator(), null); } if (this.isLengthRapport()) { scaleDashArrayLength(dashLengths, shp); } int i = 0; int j = 0; //while (ShapeHelper.getLineLength(chute) > 0) { while (chute != null) { List<Shape> splitLine = ShapeHelper.splitLine(chute, dashLengths[j]); Shape seg = splitLine.remove(0); if (splitLine.size() > 0) { chute = splitLine.remove(0); } else { chute = null; } if (i % 2 == 0) { // i.e seg to draw fragments.add(seg); } // else means blank space j = (j + 1) % split.length; i++; } if (paint != null) { g2.setPaint(paint); g2.setStroke(bs); } for (Shape seg : fragments) { List<Shape> ses = ShapeHelper.perpendicularOffset(seg, offset); for (Shape oSeg : ses) { if (oSeg != null) { if (paint != null) { g2.draw(oSeg); } else { Shape outline = bs.createStrokedShape(oSeg); fill.draw(g2, map, outline, selected, mt); } } } } } else { BasicStroke stroke; stroke = this.createBasicStroke(map, shp, mt, null /*ShapeHelper.getAreaPerimeterLength(shp)*/, true); g2.setPaint(paint); g2.setStroke(stroke); if (Math.abs(offset) > 0.0) { List<Shape> ses = ShapeHelper.perpendicularOffset(shp, offset); for (Shape oShp : ses) { if (oShp != null) { if (paint != null) { //g2.setStroke(stroke); //g2.setPaint(paint); g2.draw(oShp); } else { Shape outline = stroke.createStrokedShape(oShp); fill.draw(g2, map, outline, selected, mt); } } } } else { if (paint != null) { // Some fill type can be converted to a texture paint or a solid color //g2.setStroke(stroke); //g2.setPaint(paint); g2.draw(shp); } else { // Others can't -> create the ares to fill Shape outline = stroke.createStrokedShape(shp); fill.draw(g2, map, outline, selected, mt); } } } } } } /** * Gets the width, in pixels, of the lines that will be drawn using this {@code PenStroke}. * @param map * @param mt * @return * @throws ParameterException */ public double getWidthInPixel(Map<String,Object> map, MapTransform mt) throws ParameterException { if (this.width != null) { return Uom.toPixel(width.getValue(map), this.getUom(), mt.getDpi(), mt.getScaleDenominator(), null); } else { return DEFAULT_WIDTH_PX; } } /** * Get the minimal length needed to display a complete dash pattern, including * the dash offset. * @param map * @param mt * @return * @throws ParameterException */ public double getMinLength(Map<String,Object> map, MapTransform mt) throws ParameterException { double length = 0; if (dashArray != null) { String sDash = this.dashArray.getValue(map); String[] splitedDash = sDash.split(" "); int size = splitedDash.length; for (int i = 0; i < size; i++) { length += Uom.toPixel(Double.parseDouble(splitedDash[i]), getUom(), mt.getDpi(), mt.getScaleDenominator(), null); } } if (dashOffset != null) { length += dashOffset.getValue(map); } return length; } @Override public JAXBElement<PenStrokeType> getJAXBElement() { ObjectFactory of = new ObjectFactory(); return of.createPenStroke(this.getJAXBType()); } /** * Get a representation of this {@code PenStroke} as a jaxb type. * @return */ public PenStrokeType getJAXBType() { PenStrokeType s = new PenStrokeType(); this.setJAXBProperties(s); if (this.getOwnUom()!= null) { s.setUom(getOwnUom().toURN()); } if (this.fill != null) { s.setFill(fill.getJAXBElement()); } if (this.dashArray != null) { //s.setDashArray(null); s.setDashArray(dashArray.getJAXBParameterValueType()); } if (this.dashOffset != null) { s.setDashOffset(this.dashOffset.getJAXBParameterValueType()); } if (this.lineCap != null) { s.setLineCap(this.lineCap.getParameterValueType()); } if (this.lineJoin != null) { s.setLineJoin(this.lineJoin.getParameterValueType()); } if (this.width != null) { s.setWidth(this.width.getJAXBParameterValueType()); } return s; } }