/// <summary> /// Loads visible place names from one file. /// If cache files are appropriately named only files in view are hit /// </summary> void UpdateNames(WorldWindWFSPlacenameFile placenameFileDescriptor, ArrayList tempPlacenames, DrawArgs drawArgs) { // TODO: Replace with bounding box frustum intersection test double viewRange = drawArgs.WorldCamera.TrueViewRange.Degrees; double north = drawArgs.WorldCamera.Latitude.Degrees + viewRange; double south = drawArgs.WorldCamera.Latitude.Degrees - viewRange; double west = drawArgs.WorldCamera.Longitude.Degrees - viewRange; double east = drawArgs.WorldCamera.Longitude.Degrees + viewRange; //TODO: Implement GML parsing if (placenameFileDescriptor.north < south) { return; } if (placenameFileDescriptor.south > north) { return; } if (placenameFileDescriptor.east < west) { return; } if (placenameFileDescriptor.west > east) { return; } WorldWindPlacename[] tilednames = placenameFileDescriptor.PlaceNames; if (tilednames == null) { return; } tempPlacenames.Capacity = tempPlacenames.Count + tilednames.Length; WorldWindPlacename curPlace = new WorldWindPlacename(); for (int i = 0; i < tilednames.Length; i++) { if (m_placeNames != null && curPlaceNameIndex < m_placeNames.Length) { curPlace = m_placeNames[curPlaceNameIndex]; } WorldWindPlacename pn = tilednames[i]; float lat = pn.Lat; float lon = pn.Lon; // for easier hit testing float lonRanged = lon; if (lonRanged < west) { lonRanged += 360; // add a revolution } if (lat > north || lat < south || lonRanged > east || lonRanged < west) { continue; } float elevation = 0; if (m_parentWorld.TerrainAccessor != null && drawArgs.WorldCamera.Altitude < 300000) { elevation = (float)m_parentWorld.TerrainAccessor.GetElevationAt(lat, lon); } float altitude = (float)(m_parentWorld.EquatorialRadius + World.Settings.VerticalExaggeration * m_altitude + World.Settings.VerticalExaggeration * elevation); pn.cartesianPoint = MathEngine.SphericalToCartesian(lat, lon, altitude); float distanceSq = Vector3.LengthSq(pn.cartesianPoint - drawArgs.WorldCamera.Position); if (distanceSq > m_maximumDistanceSq) { continue; } if (distanceSq < m_minimumDistanceSq) { continue; } if (!drawArgs.WorldCamera.ViewFrustum.ContainsPoint(pn.cartesianPoint)) { continue; } tempPlacenames.Add(pn); } }
private void ProcessCacheFile() { try { string cachefilename = m_cache.CacheDirectory + string.Format("\\{0}\\WFS\\{1}\\{2}_{3}_{4}_{5}.xml.gz", this.m_world.Name, this.name, this.west, this.south, this.east, this.north); GZipInputStream instream = new GZipInputStream(new FileStream(cachefilename, FileMode.Open)); XmlDocument gmldoc = new XmlDocument(); gmldoc.Load(instream); XmlNamespaceManager xmlnsManager = new XmlNamespaceManager(gmldoc.NameTable); xmlnsManager.AddNamespace("gml", "http://www.opengis.net/gml"); //HACK: Create namespace using first part of Label Field string labelnmspace = labelfield.Split(':')[0]; if (labelnmspace == "cite") { xmlnsManager.AddNamespace(labelnmspace, "http://www.opengeospatial.net/cite"); } else if (labelnmspace == "topp") { xmlnsManager.AddNamespace(labelnmspace, "http://www.openplans.org/topp"); } XmlNodeList featureList = gmldoc.SelectNodes("//gml:featureMember", xmlnsManager); if (featureList != null) { ArrayList placenameList = new ArrayList(); foreach (XmlNode featureTypeNode in featureList) { XmlNode typeNameNode = featureTypeNode.SelectSingleNode(typename, xmlnsManager); if (typeNameNode == null) { Log.Write(Log.Levels.Debug, "No typename node: " + typename); continue; } XmlNode labelNode = typeNameNode.SelectSingleNode(labelfield, xmlnsManager); if (labelNode == null) { Log.Write(Log.Levels.Debug, "No label node: " + labelfield); continue; } XmlNodeList gmlCoordinatesNodes = featureTypeNode.SelectNodes(".//gml:Point/gml:coordinates", xmlnsManager); if (gmlCoordinatesNodes != null) { foreach (XmlNode gmlCoordinateNode in gmlCoordinatesNodes) { //Log.Write(Log.Levels.Debug, "FOUND " + gmlCoordinatesNode.Count.ToString() + " POINTS"); string coordinateNodeText = gmlCoordinateNode.InnerText; string[] coords = coordinateNodeText.Split(','); WorldWindPlacename pn = new WorldWindPlacename(); pn.Lon = float.Parse(coords[0], System.Globalization.CultureInfo.InvariantCulture); pn.Lat = float.Parse(coords[1], System.Globalization.CultureInfo.InvariantCulture); pn.Name = labelNode.InnerText; placenameList.Add(pn); } } } m_placeNames = (WorldWindPlacename[])placenameList.ToArray(typeof(WorldWindPlacename)); } if (m_placeNames == null) { m_placeNames = new WorldWindPlacename[0]; } } catch //(Exception ex) { //Log.Write(ex); if (m_placeNames == null) { m_placeNames = new WorldWindPlacename[0]; } m_failed = true; } finally { m_dlInProcess = false; } }
//TODO: Implement Downloading + Uncompressing + Caching private void DownloadParsePlacenames() { try { if (m_failed) { return; } //hard coded cache location wtf? //string cachefilename = // Directory.GetParent(System.Windows.Forms.Application.ExecutablePath) + // string.Format("Cache//WFS//Placenames//{0}//{1}_{2}_{3}_{4}.xml.gz", // this.name, this.west, this.south, this.east, this.north); //...let's use the location from settings instead string cachefilename = m_cache.CacheDirectory + string.Format("\\{0}\\WFS\\{1}\\{2}_{3}_{4}_{5}.xml.gz", this.m_world.Name, this.name, this.west, this.south, this.east, this.north); if (!File.Exists(cachefilename)) { WebDownload wfsdl = new WebDownload(this.wfsURL); wfsdl.DownloadFile(cachefilename); } GZipInputStream instream = new GZipInputStream(new FileStream(cachefilename, FileMode.Open)); XmlDocument gmldoc = new XmlDocument(); gmldoc.Load(instream); XmlNamespaceManager xmlnsManager = new XmlNamespaceManager(gmldoc.NameTable); xmlnsManager.AddNamespace("gml", "http://www.opengis.net/gml"); //HACK: Create namespace using first part of Label Field string labelnmspace = labelfield.Split(':')[0]; if (labelnmspace == "cite") { xmlnsManager.AddNamespace(labelnmspace, "http://www.opengeospatial.net/cite"); } else if(labelnmspace == "topp") { xmlnsManager.AddNamespace(labelnmspace, "http://www.openplans.org/topp"); } XmlNodeList featureList = gmldoc.SelectNodes("//gml:featureMember", xmlnsManager); if (featureList != null) { ArrayList placenameList = new ArrayList(); foreach (XmlNode featureTypeNode in featureList) { XmlNode typeNameNode = featureTypeNode.SelectSingleNode(typename, xmlnsManager); if (typeNameNode == null) { Log.Write(Log.Levels.Debug, "No typename node: " + typename); continue; } XmlNode labelNode = typeNameNode.SelectSingleNode(labelfield, xmlnsManager); if (labelNode == null) { Log.Write(Log.Levels.Debug, "No label node: " + labelfield); continue; } XmlNodeList gmlCoordinatesNodes = featureTypeNode.SelectNodes(".//gml:Point/gml:coordinates", xmlnsManager); if (gmlCoordinatesNodes != null) { foreach (XmlNode gmlCoordinateNode in gmlCoordinatesNodes) { //Log.Write(Log.Levels.Debug, "FOUND " + gmlCoordinatesNode.Count.ToString() + " POINTS"); string coordinateNodeText = gmlCoordinateNode.InnerText; string[] coords = coordinateNodeText.Split(','); WorldWindPlacename pn = new WorldWindPlacename(); pn.Lon = float.Parse(coords[0], System.Globalization.CultureInfo.InvariantCulture); pn.Lat = float.Parse(coords[1], System.Globalization.CultureInfo.InvariantCulture); pn.Name = labelNode.InnerText; placenameList.Add(pn); } } } m_placeNames = (WorldWindPlacename[])placenameList.ToArray(typeof(WorldWindPlacename)); } if (m_placeNames == null) m_placeNames = new WorldWindPlacename[0]; } catch //(Exception ex) { //Log.Write(ex); if (m_placeNames == null) m_placeNames = new WorldWindPlacename[0]; m_failed = true; } }
/// <summary> /// Loads visible place names from one file. /// </summary> void UpdateNames(WorldWindPlacenameFile placenameFileDescriptor, ArrayList tempPlacenames, DrawArgs drawArgs) { // TODO: Replace with bounding box frustum intersection test double viewRange = drawArgs.WorldCamera.TrueViewRange.Degrees; double north = drawArgs.WorldCamera.Latitude.Degrees + viewRange; double south = drawArgs.WorldCamera.Latitude.Degrees - viewRange; double west = drawArgs.WorldCamera.Longitude.Degrees - viewRange; double east = drawArgs.WorldCamera.Longitude.Degrees + viewRange; if (placenameFileDescriptor.north < south) { return; } if (placenameFileDescriptor.south > north) { return; } if (placenameFileDescriptor.east < west) { return; } if (placenameFileDescriptor.west > east) { return; } string dataFilePath = Path.Combine(Path.GetDirectoryName(m_placenameListFilePath), placenameFileDescriptor.dataFilename); using (BufferedStream dataFileStream = new BufferedStream(File.Open(dataFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))) using (BinaryReader dataFileReader = new BinaryReader(dataFileStream, System.Text.Encoding.ASCII)) { WorldWindPlacenameFile dataFile = new WorldWindPlacenameFile(); dataFile.dataFilename = placenameFileDescriptor.dataFilename; dataFile.north = placenameFileDescriptor.north; dataFile.south = placenameFileDescriptor.south; dataFile.west = placenameFileDescriptor.west; dataFile.east = placenameFileDescriptor.east; int numberPlacenames = dataFileReader.ReadInt32(); tempPlacenames.Capacity = tempPlacenames.Count + numberPlacenames; WorldWindPlacename curPlace = new WorldWindPlacename(); for (int i = 0; i < numberPlacenames; i++) { if (m_placeNames != null && curPlaceNameIndex < m_placeNames.Length) { curPlace = m_placeNames[curPlaceNameIndex]; } string name = dataFileReader.ReadString(); float lat = dataFileReader.ReadSingle(); float lon = dataFileReader.ReadSingle(); int c = dataFileReader.ReadInt32(); // Not in use, removed for speed // Hashtable metaData = new Hashtable(c); for (int n = 0; n < c; n++) { string key = dataFileReader.ReadString(); string keyData = dataFileReader.ReadString(); // Not in use, removed for speed //metaData.Add(key, keyData); } // for easier hit testing float lonRanged = lon; if (lonRanged < west) { lonRanged += 360; // add a revolution } if (lat > north || lat < south || lonRanged > east || lonRanged < west) { continue; } WorldWindPlacename pn = new WorldWindPlacename(); pn.Lat = lat; pn.Lon = lon; pn.Name = name; // Not in use, removed for speed //pn.metaData = metaData; float elevation = 0; if (m_parentWorld.TerrainAccessor != null && drawArgs.WorldCamera.Altitude < 300000) { elevation = (float)m_parentWorld.TerrainAccessor.GetElevationAt(lat, lon); } float altitude = (float)(m_parentWorld.EquatorialRadius + World.Settings.VerticalExaggeration * m_altitude + World.Settings.VerticalExaggeration * elevation); pn.cartesianPoint = MathEngine.SphericalToCartesian(lat, lon, altitude); float distanceSq = Vector3.LengthSq(pn.cartesianPoint - drawArgs.WorldCamera.Position); if (distanceSq > m_maximumDistanceSq) { continue; } if (distanceSq < m_minimumDistanceSq) { continue; } if (!drawArgs.WorldCamera.ViewFrustum.ContainsPoint(pn.cartesianPoint)) { continue; } tempPlacenames.Add(pn); } } }
// perform a full search in a placename set, with attributes bool PlaceNameSetFullSearch(string [] searchTokens, IndexedTiledPlaceNameSet curIndexedTiledSet) { DirectoryInfo dir = new DirectoryInfo(Path.GetDirectoryName( Path.Combine( Path.GetDirectoryName(Application.ExecutablePath), curIndexedTiledSet.placenameSet.PlacenameListFilePath.Value))); // ignore this set if the corresponding directory does not exist for some reason if(!dir.Exists) return true; // loop over all WWP files in directory foreach(FileInfo placenameFile in dir.GetFiles("*.wwp")) { using(BinaryReader reader = new BinaryReader(placenameFile.OpenRead()) ) { int placenameCount = reader.ReadInt32(); // loop over all places for(int i = 0; i < placenameCount; i++) { // return false if stop requested if(CheckStopRequested()) return false; // instantiate and read current placename WorldWindPlacename pn = new WorldWindPlacename(); WplIndex.ReadPlaceName(reader, ref pn, WplIndex.MetaDataAction.Store); // if we have a match ... if(isPlaceMatched(searchTokens, pn)) { if(CheckMaxResults()) return false; PlaceItem pi = new PlaceItem(); pi.pn = pn; pi.placeDescriptor = curIndexedTiledSet.placenameSet; // add item via delegate to avoid MT issues listViewResults.Invoke(listViewResults.addPlaceDelegate, new object[] { pi }); } } } } return true; // go on }
/// <summary> /// Loads visible place names from one file. /// If cache files are appropriately named only files in view are hit /// </summary> void UpdateNames(WorldWindWFSPlacenameFile placenameFileDescriptor, ArrayList tempPlacenames, DrawArgs drawArgs) { // TODO: Replace with bounding box frustum intersection test double viewRange = drawArgs.WorldCamera.TrueViewRange.Degrees; double north = drawArgs.WorldCamera.Latitude.Degrees + viewRange; double south = drawArgs.WorldCamera.Latitude.Degrees - viewRange; double west = drawArgs.WorldCamera.Longitude.Degrees - viewRange; double east = drawArgs.WorldCamera.Longitude.Degrees + viewRange; //TODO: Implement GML parsing if(placenameFileDescriptor.north < south) return; if(placenameFileDescriptor.south > north) return; if(placenameFileDescriptor.east < west) return; if(placenameFileDescriptor.west > east) return; WorldWindPlacename[] tilednames = placenameFileDescriptor.PlaceNames; if (tilednames == null) return; tempPlacenames.Capacity = tempPlacenames.Count + tilednames.Length; WorldWindPlacename curPlace = new WorldWindPlacename(); for (int i = 0; i < tilednames.Length; i++) { if (m_placeNames != null && curPlaceNameIndex < m_placeNames.Length) curPlace = m_placeNames[curPlaceNameIndex]; WorldWindPlacename pn = tilednames[i]; float lat = pn.Lat; float lon = pn.Lon; // for easier hit testing float lonRanged = lon; if (lonRanged < west) lonRanged += 360; // add a revolution if (lat > north || lat < south || lonRanged > east || lonRanged < west) continue; float elevation = 0; if (m_parentWorld.TerrainAccessor != null && drawArgs.WorldCamera.Altitude < 300000) elevation = (float)m_parentWorld.TerrainAccessor.GetElevationAt(lat, lon); float altitude = (float)(m_parentWorld.EquatorialRadius + World.Settings.VerticalExaggeration * m_altitude + World.Settings.VerticalExaggeration * elevation); pn.cartesianPoint = MathEngine.SphericalToCartesian(lat, lon, altitude); float distanceSq = Vector3.LengthSq(pn.cartesianPoint - drawArgs.WorldCamera.Position); if (distanceSq > m_maximumDistanceSq) continue; if (distanceSq < m_minimumDistanceSq) continue; if (!drawArgs.WorldCamera.ViewFrustum.ContainsPoint(pn.cartesianPoint)) continue; tempPlacenames.Add(pn); } }
// Utility function for exhaustive search: check if place meets criteria static bool isPlaceMatched(string[] searchTokens, WorldWindPlacename pn) { char[] delimiters = new char[] {' ','(',')',','}; string targetString; if(pn.metaData != null) { // concatenate all metadata, separate with spaces StringBuilder sb = new StringBuilder(pn.Name); foreach(string str in pn.metaData.Values) { sb.Append(' '); sb.Append(str); } targetString = sb.ToString(); } else { targetString = pn.Name; } // now compute new target tokens string[] targetTokens = targetString.Split(delimiters); // Note that all searchtokens have to match before we consider a place found foreach(string curSearchToken in searchTokens) { bool found = false; foreach(string curTargetToken in targetTokens) { if(String.Compare(curSearchToken, curTargetToken, true) == 0) { found = true; break; // found this search token, move to next one } } // continue only if at least one target token was found if(!found) return false; } return true; }
private void addPlace(WorldWindPlacename pn) { ListViewItem item = new ListViewItem( new string[] { pn.Name, (string)pn.metaData["Country"], pn.Lat.ToString(), pn.Lon.ToString() } ); item.Tag = pn; listViewResults.Items.Add(item); }
/// <summary> /// utility routine: read a place info record from a BinaryReader /// </summary> /// <param name="br">Binary reader to read data from</param> /// <param name="pn">Where to write data</param> /// <param name="metaDataAction">What to do with metadata, read, skip, omit. The difference between skip and omit is that /// the latter is faster, while the former correctly positions to the next record if needed</param> static public void ReadPlaceName(BinaryReader br, ref WorldWindPlacename pn, MetaDataAction metaDataAction) { pn.Name = br.ReadString(); // get place name pn.Lat = br.ReadSingle(); // and latitude pn.Lon = br.ReadSingle(); // and longitude int metaCount = br.ReadInt32(); // number of metadata (key/value pairs) if(metaDataAction == MetaDataAction.Store) { pn.metaData = new Hashtable(); } else { pn.metaData = null; } if(metaDataAction == MetaDataAction.Omit) { return; } for(int j = 0; j < metaCount; j++) { string strKey = br.ReadString(); string strValue = br.ReadString(); // add the metadata pair if so requested if(metaDataAction == MetaDataAction.Store) pn.metaData.Add(strKey, strValue); } }
private void ProcessCacheFile() { try { string cachefilename = m_cache.CacheDirectory + string.Format("\\{0}\\WFS\\{1}\\{2}_{3}_{4}_{5}.xml.gz", this.m_world.Name, this.name, this.west, this.south, this.east, this.north); XmlDocument gmldoc = new XmlDocument(); using (GZipInputStream instream = new GZipInputStream(new FileStream(cachefilename, FileMode.Open))) { gmldoc.Load(instream); } XmlNamespaceManager xmlnsManager = new XmlNamespaceManager(gmldoc.NameTable); xmlnsManager.AddNamespace("gml", "http://www.opengis.net/gml"); //HACK: Create namespace using first part of Label Field string labelnmspace = labelfield.Split(':')[0]; if (labelnmspace == "cite") { xmlnsManager.AddNamespace(labelnmspace, "http://www.opengeospatial.net/cite"); } else if (labelnmspace == "topp") { xmlnsManager.AddNamespace(labelnmspace, "http://www.openplans.org/topp"); } XmlNodeList featureList = gmldoc.SelectNodes("//gml:featureMember", xmlnsManager); if (featureList != null) { ArrayList placenameList = new ArrayList(); foreach (XmlNode featureTypeNode in featureList) { XmlNode typeNameNode = featureTypeNode.SelectSingleNode(typename, xmlnsManager); if (typeNameNode == null) { Log.Write(Log.Levels.Debug, "No typename node: " + typename); continue; } XmlNode labelNode = typeNameNode.SelectSingleNode(labelfield, xmlnsManager); if (labelNode == null) { Log.Write(Log.Levels.Debug, "No label node: " + labelfield); continue; } XmlNodeList gmlCoordinatesNodes = featureTypeNode.SelectNodes(".//gml:Point/gml:coordinates", xmlnsManager); if (gmlCoordinatesNodes != null) { foreach (XmlNode gmlCoordinateNode in gmlCoordinatesNodes) { //Log.Write(Log.Levels.Debug, "FOUND " + gmlCoordinatesNode.Count.ToString() + " POINTS"); string coordinateNodeText = gmlCoordinateNode.InnerText; string[] coords = coordinateNodeText.Split(','); WorldWindPlacename pn = new WorldWindPlacename(); pn.Lon = float.Parse(coords[0], System.Globalization.CultureInfo.InvariantCulture); pn.Lat = float.Parse(coords[1], System.Globalization.CultureInfo.InvariantCulture); pn.Name = labelNode.InnerText; placenameList.Add(pn); } } } m_placeNames = (WorldWindPlacename[])placenameList.ToArray(typeof(WorldWindPlacename)); } if (m_placeNames == null) m_placeNames = new WorldWindPlacename[0]; } catch //(Exception ex) { //Log.Write(ex); if (m_placeNames == null) m_placeNames = new WorldWindPlacename[0]; m_failed = true; } finally { m_dlInProcess = false; } }
/// <summary> /// Loads visible place names from one file. /// </summary> private void UpdateNames(WorldWindPlacenameFile placenameFileDescriptor, ArrayList tempPlacenames, DrawArgs drawArgs) { // TODO: Replace with bounding box frustum intersection test double viewRange = drawArgs.WorldCamera.TrueViewRange.Degrees; double north = drawArgs.WorldCamera.Latitude.Degrees + viewRange; double south = drawArgs.WorldCamera.Latitude.Degrees - viewRange; double west = drawArgs.WorldCamera.Longitude.Degrees - viewRange; double east = drawArgs.WorldCamera.Longitude.Degrees + viewRange; if (placenameFileDescriptor.north < south) { return; } if (placenameFileDescriptor.south > north) { return; } if (placenameFileDescriptor.east < west) { return; } if (placenameFileDescriptor.west > east) { return; } string dataFilePath = Path.Combine(Path.GetDirectoryName(m_placenameListFilePath), placenameFileDescriptor.dataFilename); using (BufferedStream dataFileStream = new BufferedStream(File.Open(dataFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))) { using (BinaryReader dataFileReader = new BinaryReader(dataFileStream, Encoding.UTF8)) { WorldWindPlacenameFile dataFile = new WorldWindPlacenameFile(); dataFile.dataFilename = placenameFileDescriptor.dataFilename; dataFile.north = placenameFileDescriptor.north; dataFile.south = placenameFileDescriptor.south; dataFile.west = placenameFileDescriptor.west; dataFile.east = placenameFileDescriptor.east; int numberPlacenames = dataFileReader.ReadInt32(); tempPlacenames.Capacity = tempPlacenames.Count + numberPlacenames; WorldWindPlacename curPlace = new WorldWindPlacename(); for (int i = 0; i < numberPlacenames; i++) { if (m_placeNames != null && curPlaceNameIndex < m_placeNames.Length) { curPlace = m_placeNames[curPlaceNameIndex]; } string name = dataFileReader.ReadString(); float lat = dataFileReader.ReadSingle(); float lon = dataFileReader.ReadSingle(); int c = dataFileReader.ReadInt32(); // Not in use, removed for speed // Hashtable metaData = new Hashtable(c); for (int n = 0; n < c; n++) { string key = dataFileReader.ReadString(); string keyData = dataFileReader.ReadString(); // Not in use, removed for speed //metaData.Add(key, keyData); } // for easier hit testing float lonRanged = lon; if (lonRanged < west) { lonRanged += 360; // add a revolution } if (lat > north || lat < south || lonRanged > east || lonRanged < west) { continue; } WorldWindPlacename pn = new WorldWindPlacename(); pn.Lat = lat; pn.Lon = lon; pn.Name = name; // Not in use, removed for speed //pn.metaData = metaData; float elevation = 0; if (m_parentWorld.TerrainAccessor != null && drawArgs.WorldCamera.Altitude < 300000) { elevation = (float) m_parentWorld.TerrainAccessor.GetElevationAt(lat, lon); } float altitude = (float) (m_parentWorld.EquatorialRadius + World.Settings.VerticalExaggeration*m_altitude + World.Settings.VerticalExaggeration*elevation); pn.cartesianPoint = MathEngine.SphericalToCartesian(lat, lon, altitude); float distanceSq = Vector3.LengthSq(pn.cartesianPoint - drawArgs.WorldCamera.Position); if (distanceSq > m_maximumDistanceSq) { continue; } if (distanceSq < m_minimumDistanceSq) { continue; } if (!drawArgs.WorldCamera.ViewFrustum.ContainsPoint(pn.cartesianPoint)) { continue; } tempPlacenames.Add(pn); } } } }