root / trunk / extensions / extGraph_predes / src / com / iver / cit / gvsig / topology / geomgraph / SnappingNodeMap.java @ 8004
History | View | Annotate | Download (16.3 KB)
1 |
/*
|
---|---|
2 |
* Created on 11-sep-2006 by azabala
|
3 |
*
|
4 |
*/
|
5 |
package com.iver.cit.gvsig.topology.geomgraph; |
6 |
|
7 |
import java.util.ArrayList; |
8 |
import java.util.Collection; |
9 |
import java.util.Collections; |
10 |
import java.util.Comparator; |
11 |
import java.util.HashMap; |
12 |
import java.util.Iterator; |
13 |
import java.util.List; |
14 |
|
15 |
import com.iver.cit.gvsig.topology.algorithms.SnapSimplePointInAreaLocator; |
16 |
import com.vividsolutions.jts.geom.Coordinate; |
17 |
import com.vividsolutions.jts.geom.Envelope; |
18 |
import com.vividsolutions.jts.geom.Location; |
19 |
import com.vividsolutions.jts.geom.TopologyException; |
20 |
import com.vividsolutions.jts.geomgraph.DirectedEdge; |
21 |
import com.vividsolutions.jts.geomgraph.DirectedEdgeStar; |
22 |
import com.vividsolutions.jts.geomgraph.Edge; |
23 |
import com.vividsolutions.jts.geomgraph.EdgeEnd; |
24 |
import com.vividsolutions.jts.geomgraph.EdgeEndStar; |
25 |
import com.vividsolutions.jts.geomgraph.GeometryGraph; |
26 |
import com.vividsolutions.jts.geomgraph.Label; |
27 |
import com.vividsolutions.jts.geomgraph.Node; |
28 |
import com.vividsolutions.jts.geomgraph.NodeFactory; |
29 |
import com.vividsolutions.jts.geomgraph.NodeMap; |
30 |
import com.vividsolutions.jts.geomgraph.Position; |
31 |
import com.vividsolutions.jts.util.Assert; |
32 |
|
33 |
/**
|
34 |
* Repository of nodes of a PlanarGraph that applies a snap tolerance.
|
35 |
*
|
36 |
* If we ask for a node, and it founds a node in the tolerance distance it
|
37 |
* returns the found node.
|
38 |
*
|
39 |
*
|
40 |
*
|
41 |
* @author alzabord
|
42 |
*/
|
43 |
public class SnappingNodeMap extends NodeMap { |
44 |
|
45 |
// private Quadtree spatialIndex;
|
46 |
|
47 |
// Esto es una mierda, y va a ir muy lento
|
48 |
// buscar otros mecanismos (indice espacial, o hashmap)
|
49 |
protected HashMap nodeMap; |
50 |
|
51 |
private class SnapDirectedEdgeStar extends DirectedEdgeStar { |
52 |
|
53 |
/**
|
54 |
* The location of the point for this star in Geometry i Areas
|
55 |
*/
|
56 |
private int[] ptInAreaLocation = { Location.NONE, Location.NONE }; |
57 |
|
58 |
private Label label; |
59 |
|
60 |
List resultAreaEdgeList = null; |
61 |
|
62 |
List getResultAreaEdges() {
|
63 |
// print(System.out);
|
64 |
if (resultAreaEdgeList != null) |
65 |
return resultAreaEdgeList;
|
66 |
resultAreaEdgeList = new ArrayList(); |
67 |
for (Iterator it = iterator(); it.hasNext();) { |
68 |
DirectedEdge de = (DirectedEdge) it.next(); |
69 |
if (de.isInResult() || de.getSym().isInResult())
|
70 |
resultAreaEdgeList.add(de); |
71 |
} |
72 |
return resultAreaEdgeList;
|
73 |
} |
74 |
|
75 |
private final int SCANNING_FOR_INCOMING = 1; |
76 |
|
77 |
private final int LINKING_TO_OUTGOING = 2; |
78 |
|
79 |
private SnapDirectedEdgeStar() {
|
80 |
super();
|
81 |
|
82 |
edgeList = new ArrayList(); |
83 |
|
84 |
} |
85 |
|
86 |
/**
|
87 |
* Traverse the star of DirectedEdges, linking the included edges
|
88 |
* together. To link two dirEdges, the <next> pointer for an incoming
|
89 |
* dirEdge is set to the next outgoing edge.
|
90 |
* <p>
|
91 |
* DirEdges are only linked if:
|
92 |
* <ul>
|
93 |
* <li>they belong to an area (i.e. they have sides)
|
94 |
* <li>they are marked as being in the result
|
95 |
* </ul>
|
96 |
* <p>
|
97 |
* Edges are linked in CCW order (the order they are stored). This means
|
98 |
* that rings have their face on the Right (in other words, the
|
99 |
* topological location of the face is given by the RHS label of the
|
100 |
* DirectedEdge)
|
101 |
* <p>
|
102 |
* PRECONDITION: No pair of dirEdges are both marked as being in the
|
103 |
* result
|
104 |
*/
|
105 |
public void linkResultDirectedEdges() { |
106 |
// make sure edges are copied to resultAreaEdges list
|
107 |
List resultEdges = getResultAreaEdges();
|
108 |
// find first area edge (if any) to start linking at
|
109 |
DirectedEdge firstOut = null;
|
110 |
DirectedEdge incoming = null;
|
111 |
int state = SCANNING_FOR_INCOMING;
|
112 |
// link edges in CCW order
|
113 |
for (int i = 0; i < resultAreaEdgeList.size(); i++) { |
114 |
DirectedEdge nextOut = (DirectedEdge) resultEdges.get(i); |
115 |
DirectedEdge nextIn = nextOut.getSym(); |
116 |
|
117 |
// skip de's that we're not interested in
|
118 |
if (!nextOut.getLabel().isArea())
|
119 |
continue;
|
120 |
|
121 |
// record first outgoing edge, in order to link the last
|
122 |
// incoming edge
|
123 |
if (firstOut == null && nextOut.isInResult()) |
124 |
firstOut = nextOut; |
125 |
// assert: sym.isInResult() == false, since pairs of dirEdges
|
126 |
// should have been removed already
|
127 |
|
128 |
switch (state) {
|
129 |
case SCANNING_FOR_INCOMING:
|
130 |
if (!nextIn.isInResult())
|
131 |
continue;
|
132 |
incoming = nextIn; |
133 |
state = LINKING_TO_OUTGOING; |
134 |
break;
|
135 |
case LINKING_TO_OUTGOING:
|
136 |
if (!nextOut.isInResult())
|
137 |
continue;
|
138 |
incoming.setNext(nextOut); |
139 |
state = SCANNING_FOR_INCOMING; |
140 |
break;
|
141 |
} |
142 |
}// for
|
143 |
if (state == LINKING_TO_OUTGOING) {
|
144 |
// Debug.print(firstOut == null, this);
|
145 |
if (firstOut == null) |
146 |
throw new TopologyException("no outgoing dirEdge found", |
147 |
getCoordinate()); |
148 |
// Assert.isTrue(firstOut != null, "no outgoing dirEdge found
|
149 |
// (at " + getCoordinate() );
|
150 |
Assert.isTrue(firstOut.isInResult(), |
151 |
"unable to link last incoming dirEdge");
|
152 |
incoming.setNext(firstOut); |
153 |
} |
154 |
} |
155 |
|
156 |
/**
|
157 |
* Insert an EdgeEnd into the map, and clear the edgeList cache, since
|
158 |
* the list of edges has now changed
|
159 |
*/
|
160 |
protected void insertEdgeEnd(EdgeEnd e, Object obj) { |
161 |
edgeMap.put(e, obj); |
162 |
if (edgeList == null) |
163 |
edgeList = new ArrayList(); |
164 |
edgeList.add(e); |
165 |
|
166 |
// Necesitamos que la "estrella" de Ejes asociada a un Nodo conserve
|
167 |
// siempre un orden antihorario. Por eso, cada vez que se a?ada un
|
168 |
// nuevo
|
169 |
// eje solo se inserta en el Map (que mantiene el orden)
|
170 |
// y la cache se pone a null
|
171 |
|
172 |
// edgeList = null;
|
173 |
} |
174 |
|
175 |
public Iterator iterator() { |
176 |
return getEdges().iterator();
|
177 |
} |
178 |
|
179 |
public List getEdges() { |
180 |
Collections.sort(edgeList, new Comparator() { |
181 |
public int compare(Object arg0, Object arg1) { |
182 |
EdgeEnd e0 = (EdgeEnd) arg0; |
183 |
EdgeEnd e1 = (EdgeEnd) arg1; |
184 |
return e0.compareTo(e1);
|
185 |
} |
186 |
}); |
187 |
// if (edgeList == null) {
|
188 |
// edgeList = new ArrayList(edgeMap.values());
|
189 |
// }
|
190 |
return edgeList;
|
191 |
} |
192 |
|
193 |
public Label getLabel() { |
194 |
return label;
|
195 |
} |
196 |
|
197 |
void computeEdgeEndLabels() {
|
198 |
// Compute edge label for each EdgeEnd
|
199 |
for (Iterator it = iterator(); it.hasNext();) { |
200 |
EdgeEnd ee = (EdgeEnd) it.next(); |
201 |
ee.computeLabel(); |
202 |
} |
203 |
} |
204 |
|
205 |
void propagateSideLabels(int geomIndex) { |
206 |
// Since edges are stored in CCW order around the node,
|
207 |
// As we move around the ring we move from the right to the left
|
208 |
// side of the edge
|
209 |
int startLoc = Location.NONE;
|
210 |
// initialize loc to location of last L side (if any)
|
211 |
// System.out.println("finding start location");
|
212 |
for (Iterator it = iterator(); it.hasNext();) { |
213 |
EdgeEnd e = (EdgeEnd) it.next(); |
214 |
Label label = e.getLabel();
|
215 |
if (label.isArea(geomIndex)
|
216 |
&& label.getLocation(geomIndex, Position.LEFT) != Location.NONE)
|
217 |
startLoc = label.getLocation(geomIndex, Position.LEFT);
|
218 |
} |
219 |
// no labelled sides found, so no labels to propagate
|
220 |
if (startLoc == Location.NONE)
|
221 |
return;
|
222 |
|
223 |
int currLoc = startLoc;
|
224 |
for (Iterator it = iterator(); it.hasNext();) { |
225 |
EdgeEnd e = (EdgeEnd) it.next(); |
226 |
Label label = e.getLabel();
|
227 |
// set null ON values to be in current location
|
228 |
if (label.getLocation(geomIndex, Position.ON) == Location.NONE) |
229 |
label.setLocation(geomIndex, Position.ON, currLoc);
|
230 |
// set side labels (if any)
|
231 |
// if (label.isArea()) { //ORIGINAL
|
232 |
if (label.isArea(geomIndex)) {
|
233 |
int leftLoc = label.getLocation(geomIndex, Position.LEFT); |
234 |
int rightLoc = label.getLocation(geomIndex, Position.RIGHT); |
235 |
// if there is a right location, that is the next location
|
236 |
// to propagate
|
237 |
if (rightLoc != Location.NONE) {
|
238 |
// Debug.print(rightLoc != currLoc, this);
|
239 |
if (rightLoc != currLoc)
|
240 |
throw new TopologyException( |
241 |
"side location conflict", e.getCoordinate());
|
242 |
if (leftLoc == Location.NONE) {
|
243 |
Assert |
244 |
.shouldNeverReachHere("found single null side (at "
|
245 |
+ e.getCoordinate() + ")");
|
246 |
} |
247 |
currLoc = leftLoc; |
248 |
} else {
|
249 |
/**
|
250 |
* RHS is null - LHS must be null too. This must be an
|
251 |
* edge from the other geometry, which has no location
|
252 |
* labelling for this geometry. This edge must lie
|
253 |
* wholly inside or outside the other geometry (which is
|
254 |
* determined by the current location). Assign both
|
255 |
* sides to be the current location.
|
256 |
*/
|
257 |
Assert.isTrue(label.getLocation(geomIndex, |
258 |
Position.LEFT) == Location.NONE,
|
259 |
"found single null side");
|
260 |
label.setLocation(geomIndex, Position.RIGHT, currLoc);
|
261 |
label.setLocation(geomIndex, Position.LEFT, currLoc);
|
262 |
} |
263 |
} |
264 |
} |
265 |
} |
266 |
|
267 |
int getLocation(int geomIndex, Coordinate p, GeometryGraph[] geom) { |
268 |
// compute location only on demand
|
269 |
if (ptInAreaLocation[geomIndex] == Location.NONE) {
|
270 |
ptInAreaLocation[geomIndex] = SnapSimplePointInAreaLocator |
271 |
.locate(p, geom[geomIndex].getGeometry(), snapTolerance); |
272 |
} |
273 |
return ptInAreaLocation[geomIndex];
|
274 |
} |
275 |
|
276 |
public void mergeSymLabels() { |
277 |
for (Iterator it = iterator(); it.hasNext();) { |
278 |
DirectedEdge de = (DirectedEdge) it.next(); |
279 |
Label label = de.getLabel();
|
280 |
Label symLabel = de.getSym().getLabel();
|
281 |
label.merge(symLabel); |
282 |
System.currentTimeMillis();
|
283 |
} |
284 |
} |
285 |
|
286 |
/**
|
287 |
* Update incomplete dirEdge labels from the labelling for the node
|
288 |
*/
|
289 |
public void updateLabelling(Label nodeLabel) { |
290 |
for (Iterator it = iterator(); it.hasNext();) { |
291 |
DirectedEdge de = (DirectedEdge) it.next(); |
292 |
Label label = de.getLabel();
|
293 |
label.setAllLocationsIfNull(0, nodeLabel.getLocation(0)); |
294 |
label.setAllLocationsIfNull(1, nodeLabel.getLocation(1)); |
295 |
} |
296 |
} |
297 |
|
298 |
public void computeLabelling(GeometryGraph[] geom) { |
299 |
computeEdgeEndLabels(); |
300 |
propagateSideLabels(0);
|
301 |
propagateSideLabels(1);
|
302 |
boolean[] hasDimensionalCollapseEdge = { false, false }; |
303 |
for (Iterator it = iterator(); it.hasNext();) { |
304 |
EdgeEnd e = (EdgeEnd) it.next(); |
305 |
Label label = e.getLabel();
|
306 |
for (int geomi = 0; geomi < 2; geomi++) { |
307 |
if (label.isLine(geomi)
|
308 |
&& label.getLocation(geomi) == Location.BOUNDARY) |
309 |
hasDimensionalCollapseEdge[geomi] = true;
|
310 |
} |
311 |
} |
312 |
for (Iterator it = iterator(); it.hasNext();) { |
313 |
EdgeEnd e = (EdgeEnd) it.next(); |
314 |
Label label = e.getLabel();
|
315 |
for (int geomi = 0; geomi < 2; geomi++) { |
316 |
if (label.isAnyNull(geomi)) {
|
317 |
int loc = Location.NONE;
|
318 |
if (hasDimensionalCollapseEdge[geomi]) {
|
319 |
loc = Location.EXTERIOR; |
320 |
} else {
|
321 |
Coordinate p = e.getCoordinate(); |
322 |
loc = getLocation(geomi, p, geom); |
323 |
} |
324 |
label.setAllLocationsIfNull(geomi, loc); |
325 |
}// if
|
326 |
}// for
|
327 |
}// for
|
328 |
|
329 |
// determine the overall labelling for this DirectedEdgeStar
|
330 |
// (i.e. for the node it is based at)
|
331 |
label = new Label(Location.NONE); |
332 |
for (Iterator it = iterator(); it.hasNext();) { |
333 |
EdgeEnd ee = (EdgeEnd) it.next(); |
334 |
Edge e = ee.getEdge(); |
335 |
Label eLabel = e.getLabel();
|
336 |
for (int i = 0; i < 2; i++) { |
337 |
int eLoc = eLabel.getLocation(i);
|
338 |
if (eLoc == Location.INTERIOR || eLoc == Location.BOUNDARY)
|
339 |
label.setLocation(i, Location.INTERIOR); |
340 |
} |
341 |
} |
342 |
} |
343 |
} |
344 |
|
345 |
class SnapNode extends Node { |
346 |
|
347 |
public SnapNode(Coordinate arg0, EdgeEndStar arg1) {
|
348 |
super(arg0, arg1);
|
349 |
} |
350 |
|
351 |
public boolean equals(Object obj) { |
352 |
SnapNode other = (SnapNode) obj; |
353 |
return other.coord.distance(this.coord) <= snapTolerance; |
354 |
} |
355 |
|
356 |
public int hashCode() { |
357 |
return 1; // esto no es eficiente |
358 |
} |
359 |
} |
360 |
|
361 |
private double snapTolerance; |
362 |
|
363 |
public SnappingNodeMap(NodeFactory nodeFactory, double snapTolerance) { |
364 |
super(nodeFactory);
|
365 |
this.snapTolerance = snapTolerance;
|
366 |
// spatialIndex = new Quadtree();
|
367 |
nodeMap = new HashMap(); |
368 |
} |
369 |
|
370 |
class MinDistCoordComparator implements Comparator { |
371 |
Coordinate coord; |
372 |
|
373 |
MinDistCoordComparator(Coordinate coord) { |
374 |
this.coord = coord;
|
375 |
} |
376 |
|
377 |
public int compare(Object arg0, Object arg1) { |
378 |
Coordinate c1 = ((Node) arg0).getCoordinate(); |
379 |
Coordinate c2 = ((Node) arg1).getCoordinate(); |
380 |
|
381 |
double d1 = c1.distance(coord);
|
382 |
double d2 = c2.distance(coord);
|
383 |
|
384 |
if (d1 < d2)
|
385 |
return 1; |
386 |
if (d1 > d2)
|
387 |
return -1; |
388 |
else
|
389 |
return 0; |
390 |
} |
391 |
} |
392 |
|
393 |
private Envelope getQueryRect(Coordinate coord) {
|
394 |
double xmin = coord.x - snapTolerance;
|
395 |
double ymin = coord.y - snapTolerance;
|
396 |
double xmax = coord.x + snapTolerance;
|
397 |
double ymax = coord.y + snapTolerance;
|
398 |
Envelope queryRect = new Envelope(xmin, xmax, ymin, ymax);
|
399 |
return queryRect;
|
400 |
} |
401 |
|
402 |
public Node addNode(final Coordinate coord) { |
403 |
SnapNode candidate = null;
|
404 |
DirectedEdgeStar edgeStar = new SnapDirectedEdgeStar();
|
405 |
candidate = new SnapNode(coord, edgeStar);
|
406 |
|
407 |
/*
|
408 |
* PRUEBA PARA VER POR QU? DA ERRORES CON POLIGONOS
|
409 |
*
|
410 |
*
|
411 |
*
|
412 |
*/
|
413 |
|
414 |
Node stored = (Node) nodeMap.get(candidate); |
415 |
if (stored != null) |
416 |
return stored;
|
417 |
else {
|
418 |
nodeMap.put(candidate, candidate); |
419 |
return candidate;
|
420 |
} |
421 |
// Envelope queryRect = getQueryRect(coord);
|
422 |
// List nodes = spatialIndex.query(queryRect);
|
423 |
// Spatial Index est? devolviendo candidatos fuera del rectangulo
|
424 |
// ser? problema del quadtree
|
425 |
// if(nodes.size() != 0){
|
426 |
// Collections.sort(nodes, new MinDistCoordComparator(coord));
|
427 |
// Node candidate = (Node) nodes.get(0);
|
428 |
// if(candidate.getCoordinate().distance(coord) < snapTolerance )
|
429 |
// return candidate;
|
430 |
// }else{
|
431 |
// System.out.println("El nodo "+coord.x+","+coord.y+" no tiene entradas
|
432 |
// analogas en el quadtree");
|
433 |
// List all = this.spatialIndex.queryAll();
|
434 |
// System.out.println("en el quadtree "+all.size());
|
435 |
// for(int i = 0; i < all.size(); i++){
|
436 |
// Node node = (Node) all.get(i);
|
437 |
// Coordinate coord2 = node.getCoordinate();
|
438 |
// System.out.println(coord2.x + "," + coord2.y);
|
439 |
// }
|
440 |
// }
|
441 |
// Node solution = nodeFactory.createNode(coord);
|
442 |
// spatialIndex.insert(queryRect, solution);
|
443 |
// return solution;
|
444 |
} |
445 |
|
446 |
// FIXME: REVISAR SI HABR?A QUE HACER UN MERGE LABEL ARRIBA O AQU?
|
447 |
public Node addNode(Node n) {
|
448 |
|
449 |
Node node = addNode(n.getCoordinate()); |
450 |
node.mergeLabel(n); |
451 |
return node;
|
452 |
} |
453 |
|
454 |
/**
|
455 |
* Adds a node for the start point of this EdgeEnd (if one does not already
|
456 |
* exist in this map). Adds the EdgeEnd to the (possibly new) node.
|
457 |
*/
|
458 |
public void add(EdgeEnd e) { |
459 |
Coordinate p = e.getCoordinate(); |
460 |
System.out.println("Verificando nodo " + p.toString()); |
461 |
Node n = addNode(p); |
462 |
System.out.println("antes de a?adir..."); |
463 |
List list = n.getEdges().getEdges();
|
464 |
System.out.println(list.size());
|
465 |
for (int i = 0; i < list.size(); i++) { |
466 |
EdgeEnd ee = (EdgeEnd) list.get(i); |
467 |
System.out.println("edge" + i); |
468 |
Coordinate[] coords = ee.getEdge().getCoordinates();
|
469 |
String ctx = "("; |
470 |
for (int j = 0; j < coords.length; j++) { |
471 |
ctx += coords[j].x + " " + coords[j].y + ","; |
472 |
} |
473 |
ctx += ")";
|
474 |
System.out.println(ctx);
|
475 |
} |
476 |
System.out.println("despues de a?adir..."); |
477 |
n.add(e);// Si el nodo ya existe, se le a?ade una arista
|
478 |
list = n.getEdges().getEdges(); |
479 |
System.out.println(list.size());
|
480 |
for (int i = 0; i < list.size(); i++) { |
481 |
EdgeEnd ee = (EdgeEnd) list.get(i); |
482 |
System.out.println("edge" + i); |
483 |
Coordinate[] coords = ee.getEdge().getCoordinates();
|
484 |
String ctx = "("; |
485 |
for (int j = 0; j < coords.length; j++) { |
486 |
ctx += coords[j].x + " " + coords[j].y + ","; |
487 |
} |
488 |
ctx += ")";
|
489 |
System.out.println(ctx);
|
490 |
} |
491 |
} |
492 |
|
493 |
/**
|
494 |
* @return the node if found; null otherwise
|
495 |
*/
|
496 |
public Node find(Coordinate coord) {
|
497 |
// Envelope queryRect = getQueryRect(coord);
|
498 |
// List nodes = spatialIndex.query(getQueryRect(coord));
|
499 |
// Collections.sort(nodes, new MinDistCoordComparator(coord));
|
500 |
// Node candidate = (Node) nodes.get(0);
|
501 |
return (Node) nodeMap.get(new SnapNode(coord, null)); |
502 |
// return candidate;
|
503 |
} |
504 |
|
505 |
public Iterator iterator() { |
506 |
return nodeMap.values().iterator();
|
507 |
// return spatialIndex.queryAll().iterator();
|
508 |
} |
509 |
|
510 |
public Collection values() { |
511 |
return nodeMap.values();
|
512 |
// return spatialIndex.queryAll();
|
513 |
} |
514 |
|
515 |
public Collection getBoundaryNodes(int geomIndex) { |
516 |
Collection bdyNodes = new ArrayList(); |
517 |
for (Iterator i = iterator(); i.hasNext();) { |
518 |
Node node = (Node) i.next(); |
519 |
if (node.getLabel().getLocation(geomIndex) == Location.BOUNDARY)
|
520 |
bdyNodes.add(node); |
521 |
} |
522 |
return bdyNodes;
|
523 |
} |
524 |
|
525 |
public void dump() { |
526 |
System.out.println(this.toString()); |
527 |
Iterator it = iterator();
|
528 |
while (it.hasNext()) {
|
529 |
Node node = (Node) it.next(); |
530 |
Coordinate coord = node.getCoordinate(); |
531 |
System.out.println("x= " + coord.x + ", y= " + coord.y); |
532 |
} |
533 |
} |
534 |
|
535 |
public static void main(String[] args) { |
536 |
SnappingNodeMap nodeMap = new SnappingNodeMap(new NodeFactory(), 0.01); |
537 |
nodeMap.addNode(new Coordinate(0.001, 0.001)); |
538 |
nodeMap.addNode(new Coordinate(0.002, 0.002)); |
539 |
Iterator it = nodeMap.iterator();
|
540 |
while (it.hasNext()) {
|
541 |
Node node = (Node) it.next(); |
542 |
Coordinate coord = node.getCoordinate(); |
543 |
System.out.println("x= " + coord.x + ", y= " + coord.y); |
544 |
} |
545 |
} |
546 |
} |