source: osm/applications/editors/josm/plugins/cadastre-fr/src/cadastre_fr/WMSLayer.java@ 19702

Last change on this file since 19702 was 19702, checked in by pieren, 15 years ago

Fix a null pointer exception

  • Property svn:eol-style set to native
File size: 23.8 KB
Line 
1// License: GPL. v2 and later. Copyright 2008-2009 by Pieren <pieren3@gmail.com> and others
2package cadastre_fr;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.Color;
7import java.awt.Component;
8import java.awt.Graphics;
9import java.awt.Graphics2D;
10import java.awt.Image;
11import java.awt.Point;
12import java.awt.Toolkit;
13import java.awt.image.BufferedImage;
14import java.awt.image.ImageObserver;
15import java.io.EOFException;
16import java.io.IOException;
17import java.io.ObjectInputStream;
18import java.io.ObjectOutputStream;
19import java.util.ArrayList;
20import java.util.Vector;
21
22import javax.swing.Icon;
23import javax.swing.ImageIcon;
24import javax.swing.JMenuItem;
25import javax.swing.JOptionPane;
26
27import org.openstreetmap.josm.Main;
28import org.openstreetmap.josm.data.Bounds;
29import org.openstreetmap.josm.data.coor.EastNorth;
30import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
31import org.openstreetmap.josm.gui.MapView;
32import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
33import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
34import org.openstreetmap.josm.gui.layer.Layer;
35import org.openstreetmap.josm.io.OsmTransferException;
36
37/**
38 * This is a layer that grabs the current screen from the French cadastre WMS
39 * server. The data fetched this way is tiled and managed to the disc to reduce
40 * server load.
41 */
42public class WMSLayer extends Layer implements ImageObserver {
43
44 Component[] component = null;
45
46 private int lambertZone = -1;
47
48 protected static final Icon icon = new ImageIcon(Toolkit.getDefaultToolkit().createImage(
49 CadastrePlugin.class.getResource("/images/cadastre_small.png")));
50
51 protected Vector<GeorefImage> images = new Vector<GeorefImage>();
52
53 /**
54 * v1 to v2 = not supported
55 * v2 to v3 = add 4 more EastNorth coordinates in GeorefImages
56 */
57 protected final int serializeFormatVersion = 3;
58
59 public static int currentFormat;
60
61 private ArrayList<EastNorthBound> dividedBbox = new ArrayList<EastNorthBound>();
62
63 private CacheControl cacheControl = null;
64
65 private String location = "";
66
67 private String codeCommune = "";
68
69 public EastNorthBound communeBBox = new EastNorthBound(new EastNorth(0,0), new EastNorth(0,0));
70
71 private boolean isRaster = false;
72
73 private boolean isAlreadyGeoreferenced = false;
74
75 public double X0, Y0, angle, fX, fY;
76
77 // bbox of the georeferenced raster image (the nice horizontal and vertical box)
78 private EastNorth rasterMin;
79 private EastNorth rasterMax;
80 private double rasterRatio;
81
82 private JMenuItem saveAsPng;
83
84 public boolean adjustModeEnabled;
85
86 public WMSLayer() {
87 this(tr("Blank Layer"), "", -1);
88 }
89
90 public WMSLayer(String location, String codeCommune, int lambertZone) {
91 super(buildName(location, codeCommune));
92 this.location = location;
93 this.codeCommune = codeCommune;
94 this.lambertZone = lambertZone;
95 // enable auto-sourcing option
96 CadastrePlugin.pluginUsed = true;
97 }
98
99 public void destroy() {
100 // if the layer is currently saving the images in the cache, wait until it's finished
101 if (cacheControl != null) {
102 while (!cacheControl.isCachePipeEmpty()) {
103 System.out.println("Try to close a WMSLayer which is currently saving in cache : wait 1 sec.");
104 CadastrePlugin.safeSleep(1000);
105 }
106 }
107 super.destroy();
108 images = null;
109 dividedBbox = null;
110 System.out.println("Layer "+location+" destroyed");
111 }
112
113 private static String buildName(String location, String codeCommune) {
114 String ret = new String(location.toUpperCase());
115 if (codeCommune != null && !codeCommune.equals(""))
116 ret += "(" + codeCommune + ")";
117 return ret;
118 }
119
120 private String rebuildName() {
121 return buildName(this.location.toUpperCase(), this.codeCommune);
122 }
123
124 public void grab(CadastreGrabber grabber, Bounds b) throws IOException {
125 grab(grabber, b, true);
126 }
127
128 public void grab(CadastreGrabber grabber, Bounds b, boolean useFactor) throws IOException {
129 if (useFactor) {
130 if (isRaster) {
131 b = new Bounds(Main.proj.eastNorth2latlon(rasterMin), Main.proj.eastNorth2latlon(rasterMax));
132 divideBbox(b, Integer.parseInt(Main.pref.get("cadastrewms.rasterDivider",
133 CadastrePreferenceSetting.DEFAULT_RASTER_DIVIDER)));
134 } else
135 divideBbox(b, Integer.parseInt(Main.pref.get("cadastrewms.scale", Scale.X1.toString())));
136 } else
137 divideBbox(b, 1);
138
139 for (EastNorthBound n : dividedBbox) {
140 GeorefImage newImage;
141 try {
142 newImage = grabber.grab(this, n.min, n.max);
143 } catch (IOException e) {
144 System.out.println("Download action cancelled by user or server did not respond");
145 break;
146 } catch (OsmTransferException e) {
147 System.out.println("OSM transfer failed");
148 break;
149 }
150 if (grabber.getWmsInterface().downloadCancelled) {
151 System.out.println("Download action cancelled by user");
152 break;
153 }
154 if (CadastrePlugin.backgroundTransparent) {
155 for (GeorefImage img : images) {
156 if (img.overlap(newImage))
157 // mask overlapping zone in already grabbed image
158 img.withdraw(newImage);
159 else
160 // mask overlapping zone in new image only when new
161 // image covers completely the existing image
162 newImage.withdraw(img);
163 }
164 }
165 images.add(newImage);
166 saveToCache(newImage);
167 Main.map.mapView.repaint();
168 }
169 }
170
171 /**
172 *
173 * @param b the original bbox, usually the current bbox on screen
174 * @param factor 1 = source bbox 1:1
175 * 2 = source bbox divided by 2x2 smaller boxes
176 * 3 = source bbox divided by 3x3 smaller boxes
177 * 4 = hard coded size of boxes (100 meters) rounded allowing
178 * grabbing of next contiguous zone
179 */
180 private void divideBbox(Bounds b, int factor) {
181 EastNorth lambertMin = Main.proj.latlon2eastNorth(b.getMin());
182 EastNorth lambertMax = Main.proj.latlon2eastNorth(b.getMax());
183 double minEast = lambertMin.east();
184 double minNorth = lambertMin.north();
185 double dEast = (lambertMax.east() - minEast) / factor;
186 double dNorth = (lambertMax.north() - minNorth) / factor;
187 dividedBbox.clear();
188 if (factor < 4 || isRaster) {
189 for (int xEast = 0; xEast < factor; xEast++)
190 for (int xNorth = 0; xNorth < factor; xNorth++) {
191 dividedBbox.add(new EastNorthBound(new EastNorth(minEast + xEast * dEast, minNorth + xNorth * dNorth),
192 new EastNorth(minEast + (xEast + 1) * dEast, minNorth + (xNorth + 1) * dNorth)));
193 }
194 } else {
195 // divide to fixed size squares
196 int cSquare = Integer.parseInt(Main.pref.get("cadastrewms.squareSize", "100"));
197 minEast = minEast - minEast % cSquare;
198 minNorth = minNorth - minNorth % cSquare;
199 for (int xEast = (int)minEast; xEast < lambertMax.east(); xEast+=cSquare)
200 for (int xNorth = (int)minNorth; xNorth < lambertMax.north(); xNorth+=cSquare) {
201 dividedBbox.add(new EastNorthBound(new EastNorth(xEast, xNorth),
202 new EastNorth(xEast + cSquare, xNorth + cSquare)));
203 }
204 }
205 }
206
207 @Override
208 public Icon getIcon() {
209 return icon;
210 }
211
212 @Override
213 public String getToolTipText() {
214 String str = tr("WMS layer ({0}), {1} tile(s) loaded", getName(), images.size());
215 if (isRaster) {
216 str += "\n"+tr("Is not vectorized.");
217 str += "\n"+tr("Raster size: {0}", communeBBox);
218 } else
219 str += "\n"+tr("Is vectorized.");
220 str += "\n"+tr("Commune bbox: {0}", communeBBox);
221 return str;
222 }
223
224 @Override
225 public boolean isMergable(Layer other) {
226 return false;
227 }
228
229 @Override
230 public void mergeFrom(Layer from) {
231 }
232
233 @Override
234 public void paint(Graphics2D g, final MapView mv, Bounds bounds) {
235 synchronized(this){
236 for (GeorefImage img : images)
237 img.paint(g, mv, CadastrePlugin.backgroundTransparent,
238 CadastrePlugin.transparency, CadastrePlugin.drawBoundaries);
239 }
240 if (this.isRaster) {
241 paintCrosspieces(g, mv);
242 }
243 if (this.adjustModeEnabled) {
244 WMSAdjustAction.paintAdjustFrames(g, mv);
245 }
246 }
247
248 @Override
249 public void visitBoundingBox(BoundingXYVisitor v) {
250 for (GeorefImage img : images) {
251 v.visit(img.min);
252 v.visit(img.max);
253 }
254 }
255
256 @Override
257 public Object getInfoComponent() {
258 return getToolTipText();
259 }
260
261 @Override
262 public Component[] getMenuEntries() {
263 saveAsPng = new JMenuItem(new MenuActionSaveRasterAs(this));
264 saveAsPng.setEnabled(isRaster);
265 component = new Component[] { new JMenuItem(LayerListDialog.getInstance().createShowHideLayerAction(this)),
266 new JMenuItem(LayerListDialog.getInstance().createDeleteLayerAction(this)),
267 new JMenuItem(new MenuActionLoadFromCache()),
268 saveAsPng,
269 new JMenuItem(new LayerListPopup.InfoAction(this)),
270
271 };
272 return component;
273 }
274
275 public GeorefImage findImage(EastNorth eastNorth) {
276 // Iterate in reverse, so we return the image which is painted last.
277 // (i.e. the topmost one)
278 for (int i = images.size() - 1; i >= 0; i--) {
279 if (images.get(i).contains(eastNorth)) {
280 return images.get(i);
281 }
282 }
283 return null;
284 }
285
286 public boolean isOverlapping(Bounds bounds) {
287 GeorefImage georefImage =
288 new GeorefImage(new BufferedImage(1,1,BufferedImage.TYPE_INT_RGB ), // not really important
289 Main.proj.latlon2eastNorth(bounds.getMin()),
290 Main.proj.latlon2eastNorth(bounds.getMax()));
291 for (GeorefImage img : images) {
292 if (img.overlap(georefImage))
293 return true;
294 }
295 return false;
296 }
297
298 public void saveToCache(GeorefImage image) {
299 if (CacheControl.cacheEnabled && !isRaster()) {
300 getCacheControl().saveCache(image);
301 }
302 }
303
304 public void saveNewCache() {
305 if (CacheControl.cacheEnabled) {
306 getCacheControl().deleteCacheFile();
307 for (GeorefImage image : images)
308 getCacheControl().saveCache(image);
309 }
310 }
311
312 public CacheControl getCacheControl() {
313 if (cacheControl == null)
314 cacheControl = new CacheControl(this);
315 return cacheControl;
316 }
317
318 /**
319 * Convert the eastNorth input coordinates to raster coordinates.
320 * The original raster size is [0,0,12286,8730] where 0,0 is the upper left corner and
321 * 12286,8730 is the approx. raster max size.
322 * @return the raster coordinates for the wms server request URL (minX,minY,maxX,maxY)
323 */
324 public String eastNorth2raster(EastNorth min, EastNorth max) {
325 double minX = (min.east() - rasterMin.east()) / rasterRatio;
326 double minY = (min.north() - rasterMin.north()) / rasterRatio;
327 double maxX = (max.east() - rasterMin.east()) / rasterRatio;
328 double maxY = (max.north() - rasterMin.north()) / rasterRatio;
329 return minX+","+minY+","+maxX+","+maxY;
330 }
331
332
333 public String getLocation() {
334 return location;
335 }
336
337 public void setLocation(String location) {
338 this.location = location;
339 setName(rebuildName());
340 }
341
342 public String getCodeCommune() {
343 return codeCommune;
344 }
345
346 public void setCodeCommune(String codeCommune) {
347 this.codeCommune = codeCommune;
348 setName(rebuildName());
349 }
350
351 public boolean isRaster() {
352 return isRaster;
353 }
354
355 public void setRaster(boolean isRaster) {
356 this.isRaster = isRaster;
357 if (saveAsPng != null)
358 saveAsPng.setEnabled(isRaster);
359 }
360
361 public boolean isAlreadyGeoreferenced() {
362 return isAlreadyGeoreferenced;
363 }
364
365 public void setAlreadyGeoreferenced(boolean isAlreadyGeoreferenced) {
366 this.isAlreadyGeoreferenced = isAlreadyGeoreferenced;
367 }
368
369 /**
370 * Set raster positions used for grabbing and georeferencing.
371 * rasterMin is the Eaast North of bottom left corner raster image on the screen when image is grabbed.
372 * The bounds width and height are the raster width and height. The image width matches the current view
373 * and the image height is adapted.
374 * Required: the communeBBox must be set (normally it is catched by CadastreInterface and saved by DownloadWMSPlanImage)
375 * @param bounds the current main map view boundaries
376 */
377 public void setRasterBounds(Bounds bounds) {
378 EastNorth rasterCenter = Main.proj.latlon2eastNorth(bounds.getCenter());
379 EastNorth eaMin = Main.proj.latlon2eastNorth(bounds.getMin());
380 EastNorth eaMax = Main.proj.latlon2eastNorth(bounds.getMax());
381 double rasterSizeX = communeBBox.max.getX() - communeBBox.min.getX();
382 double rasterSizeY = communeBBox.max.getY() - communeBBox.min.getY();
383 double ratio = rasterSizeY/rasterSizeX;
384 // keep same ratio on screen as WMS bbox (stored in communeBBox)
385 rasterMin = new EastNorth(eaMin.getX(), rasterCenter.getY()-(eaMax.getX()-eaMin.getX())*ratio/2);
386 rasterMax = new EastNorth(eaMax.getX(), rasterCenter.getY()+(eaMax.getX()-eaMin.getX())*ratio/2);
387 rasterRatio = (rasterMax.getX()-rasterMin.getX())/rasterSizeX;
388 }
389
390 /**
391 * Called by CacheControl when a new cache file is created on disk.
392 * Save only primitives to keep cache independent of software changes.
393 * @param oos
394 * @throws IOException
395 */
396 public void write(ObjectOutputStream oos) throws IOException {
397 oos.writeInt(this.serializeFormatVersion);
398 oos.writeObject(this.location); // String
399 oos.writeObject(this.codeCommune); // String
400 oos.writeInt(this.lambertZone);
401 oos.writeBoolean(this.isRaster);
402 if (this.isRaster) {
403 oos.writeDouble(this.rasterMin.getX());
404 oos.writeDouble(this.rasterMin.getY());
405 oos.writeDouble(this.rasterMax.getX());
406 oos.writeDouble(this.rasterMax.getY());
407 oos.writeDouble(this.rasterRatio);
408 }
409 oos.writeDouble(this.communeBBox.min.getX());
410 oos.writeDouble(this.communeBBox.min.getY());
411 oos.writeDouble(this.communeBBox.max.getX());
412 oos.writeDouble(this.communeBBox.max.getY());
413 }
414
415 /**
416 * Called by CacheControl when a cache file is read from disk.
417 * Cache uses only primitives to stay independent of software changes.
418 * @param ois
419 * @throws IOException
420 * @throws ClassNotFoundException
421 */
422 public boolean read(ObjectInputStream ois, int currentLambertZone) throws IOException, ClassNotFoundException {
423 currentFormat = ois.readInt();;
424 if (currentFormat < 2) {
425 JOptionPane.showMessageDialog(Main.parent, tr("Unsupported cache file version; found {0}, expected {1}\nCreate a new one.",
426 currentFormat, this.serializeFormatVersion), tr("Cache Format Error"), JOptionPane.ERROR_MESSAGE);
427 return false;
428 }
429 this.setLocation((String) ois.readObject());
430 this.setCodeCommune((String) ois.readObject());
431 this.lambertZone = ois.readInt();
432 this.setRaster(ois.readBoolean());
433 if (this.isRaster) {
434 double X = ois.readDouble();
435 double Y = ois.readDouble();
436 this.rasterMin = new EastNorth(X, Y);
437 X = ois.readDouble();
438 Y = ois.readDouble();
439 this.rasterMax = new EastNorth(X, Y);
440 this.rasterRatio = ois.readDouble();
441 }
442 double minX = ois.readDouble();
443 double minY = ois.readDouble();
444 double maxX = ois.readDouble();
445 double maxY = ois.readDouble();
446 this.communeBBox = new EastNorthBound(new EastNorth(minX, minY), new EastNorth(maxX, maxY));
447 if (this.lambertZone != currentLambertZone && currentLambertZone != -1) {
448 JOptionPane.showMessageDialog(Main.parent, tr("Lambert zone {0} in cache "+
449 "incompatible with current Lambert zone {1}",
450 this.lambertZone+1, currentLambertZone), tr("Cache Lambert Zone Error"), JOptionPane.ERROR_MESSAGE);
451 return false;
452 }
453 synchronized(this){
454 boolean EOF = false;
455 try {
456 while (!EOF) {
457 GeorefImage newImage = (GeorefImage) ois.readObject();
458 for (GeorefImage img : this.images) {
459 if (CadastrePlugin.backgroundTransparent) {
460 if (img.overlap(newImage))
461 // mask overlapping zone in already grabbed image
462 img.withdraw(newImage);
463 else
464 // mask overlapping zone in new image only when
465 // new image covers completely the existing image
466 newImage.withdraw(img);
467 }
468 }
469 this.images.add(newImage);
470 }
471 } catch (EOFException ex) {
472 // expected exception when all images are read
473 }
474 }
475 System.out.println("Cache loaded for location "+location+" with "+images.size()+" images");
476 return true;
477 }
478
479 /**
480 * Join the grabbed images into one single.
481 * Works only for images grabbed from non-georeferenced images (Feuilles cadastrales)(same amount of
482 * images in x and y)
483 */
484 public void joinRasterImages() {
485 if (images.size() > 1) {
486 EastNorth min = images.get(0).min;
487 EastNorth max = images.get(images.size()-1).max;
488 int oldImgWidth = images.get(0).image.getWidth();
489 int oldImgHeight = images.get(0).image.getHeight();
490 int newWidth = oldImgWidth*(int)Math.sqrt(images.size());
491 int newHeight = oldImgHeight*(int)Math.sqrt(images.size());
492 BufferedImage new_img = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB);
493 Graphics g = new_img.getGraphics();
494 // Coordinate (0,0) is on top,left corner where images are grabbed from bottom left
495 int rasterDivider = (int)Math.sqrt(images.size());
496 for (int h = 0; h < rasterDivider; h++) {
497 for (int v = 0; v < rasterDivider; v++) {
498 int newx = h*oldImgWidth;
499 int newy = newHeight - oldImgHeight - (v*oldImgHeight);
500 int j = h*rasterDivider + v;
501 g.drawImage(images.get(j).image, newx, newy, this);
502 }
503 }
504 synchronized(this) {
505 images.clear();
506 images.add(new GeorefImage(new_img, min, max));
507 }
508 }
509 }
510
511 /**
512 * Image cropping based on two EN coordinates pointing to two corners in diagonal
513 * Because it's coming from user mouse clics, we have to sort de positions first.
514 * Works only for raster image layer (only one image in collection).
515 * Updates layer georeferences.
516 * @param en1
517 * @param en2
518 */
519 public void cropImage(EastNorth en1, EastNorth en2){
520 // adj1 is corner bottom, left
521 EastNorth adj1 = new EastNorth(en1.east() <= en2.east() ? en1.east() : en2.east(),
522 en1.north() <= en2.north() ? en1.north() : en2.north());
523 // adj2 is corner top, right
524 EastNorth adj2 = new EastNorth(en1.east() > en2.east() ? en1.east() : en2.east(),
525 en1.north() > en2.north() ? en1.north() : en2.north());
526 // s1 and s2 have 0,0 at top, left where all EastNorth coord. have 0,0 at bottom, left
527 int sx1 = (int)((adj1.getX() - images.get(0).min.getX())*images.get(0).getPixelPerEast());
528 int sy1 = (int)((images.get(0).max.getY() - adj2.getY())*images.get(0).getPixelPerNorth());
529 int sx2 = (int)((adj2.getX() - images.get(0).min.getX())*images.get(0).getPixelPerEast());
530 int sy2 = (int)((images.get(0).max.getY() - adj1.getY())*images.get(0).getPixelPerNorth());
531 int newWidth = Math.abs(sx2 - sx1);
532 int newHeight = Math.abs(sy2 - sy1);
533 BufferedImage new_img = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB);
534 Graphics g = new_img.getGraphics();
535 g.drawImage(images.get(0).image, 0, 0, newWidth-1, newHeight-1,
536 sx1, sy1, sx2, sy2,
537 this);
538 images.set(0, new GeorefImage(new_img, adj1, adj2));
539 // important: update the layer georefs !
540 rasterMin = adj1;
541 rasterMax = adj2;
542 rasterRatio = (rasterMax.getX()-rasterMin.getX())/(communeBBox.max.getX() - communeBBox.min.getX());
543 setCommuneBBox(new EastNorthBound(new EastNorth(0,0), new EastNorth(newWidth-1,newHeight-1)));
544 }
545
546 public EastNorthBound getCommuneBBox() {
547 return communeBBox;
548 }
549
550 public void setCommuneBBox(EastNorthBound entireCommune) {
551 this.communeBBox = entireCommune;
552 }
553
554 /**
555 * Method required by ImageObserver when drawing an image
556 */
557 public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) {
558 return false;
559 }
560
561 public int getLambertZone() {
562 return lambertZone;
563 }
564
565 public EastNorth getRasterCenter() {
566 return new EastNorth((images.get(0).max.east()+images.get(0).min.east())/2,
567 (images.get(0).max.north()+images.get(0).min.north())/2);
568 }
569
570 public void displace(double dx, double dy) {
571 this.rasterMin = new EastNorth(rasterMin.east() + dx, rasterMin.north() + dy);
572 images.get(0).shear(dx, dy);
573 }
574
575 public void resize(EastNorth rasterCenter, double proportion) {
576 this.rasterMin = rasterMin.interpolate(rasterCenter, proportion);
577 images.get(0).scale(rasterCenter, proportion);
578 }
579
580 public void rotate(EastNorth rasterCenter, double angle) {
581 this.rasterMin = rasterMin.rotate(rasterCenter, angle);
582 images.get(0).rotate(rasterCenter, angle);
583 }
584
585 private void paintCrosspieces(Graphics g, MapView mv) {
586 String crosspieces = Main.pref.get("cadastrewms.crosspieces", "0");
587 if (!crosspieces.equals("0")) {
588 int modulo = 25;
589 if (crosspieces.equals("2")) modulo = 50;
590 if (crosspieces.equals("3")) modulo = 100;
591 EastNorthBound currentView = new EastNorthBound(mv.getEastNorth(0, mv.getHeight()),
592 mv.getEastNorth(mv.getWidth(), 0));
593 int minX = ((int)currentView.min.east()/modulo+1)*modulo;
594 int minY = ((int)currentView.min.north()/modulo+1)*modulo;
595 int maxX = ((int)currentView.max.east()/modulo)*modulo;
596 int maxY = ((int)currentView.max.north()/modulo)*modulo;
597 int size=(maxX-minX)/modulo;
598 if (size<20) {
599 int px= size > 10 ? 2 : Math.abs(12-size);
600 g.setColor(Color.green);
601 for (int x=minX; x<=maxX; x+=modulo) {
602 for (int y=minY; y<=maxY; y+=modulo) {
603 Point p = mv.getPoint(new EastNorth(x,y));
604 g.drawLine(p.x-px, p.y, p.x+px, p.y);
605 g.drawLine(p.x, p.y-px, p.x, p.y+px);
606 }
607 }
608 }
609 }
610 }
611
612}
Note: See TracBrowser for help on using the repository browser.