Geef een SVG-wereld weer

Wat je gaat creëren

In deze zelfstudie laat ik je zien hoe je een SVG-kaart kunt nemen en deze als een vector op een wereldbol kunt projecteren. Om de wiskundige transformaties uit te voeren die nodig zijn om de kaart op een bol te projecteren, moeten we Python-scripting gebruiken om de kaartgegevens te lezen en deze in een afbeelding van een wereldbol te vertalen. Deze tutorial gaat ervan uit dat je Python 3.4 draait, de nieuwste beschikbare Python.

Inkscape heeft een soort van Python API die kan worden gebruikt om een ​​verscheidenheid aan dingen te doen. Aangezien we echter alleen geïnteresseerd zijn in het transformeren van vormen, is het eenvoudiger om alleen een stand-alone programma te schrijven dat SVG-bestanden zelfstandig leest en afdrukt.

1. Formatteer de kaart

Het type kaart dat we willen, wordt een equirectangular-kaart genoemd. In een rechthoekige kaart komt de lengte- en breedtegraad van een plaats overeen met die van de plaats X en Y positie op de kaart. Eén even rechthoekige wereldkaart is te vinden op Wikimedia Commons (hier is een versie met Amerikaanse staten).

SVG-coördinaten kunnen op verschillende manieren worden gedefinieerd. Ze kunnen bijvoorbeeld relatief zijn ten opzichte van het eerder gedefinieerde punt of absoluut zijn gedefinieerd vanaf de oorsprong. Om ons leven gemakkelijker te maken, willen we de coördinaten in de kaart naar de absolute vorm converteren. Inkscape kan dit. Ga naar Inkscape-voorkeuren (onder de Bewerk menu) en onder Invoer uitvoer > SVG-uitvoer, reeks Pad reeksindeling naar absoluut.

Inkscape zal de coördinaten niet automatisch converteren; je moet een soort van transformatie op de paden uitvoeren om dat te laten gebeuren. De eenvoudigste manier om dat te doen, is gewoon alles selecteren en omhoog en omlaag verplaatsen met een druk op elk van de pijlen omhoog en omlaag. Sla het bestand vervolgens opnieuw op.

2. Start uw Python-script

Maak een nieuw Python-bestand. Importeer de volgende modules:

import sys importeer import import wiskunde importeer tijd importeer datetime import numpy as np importeer xml.etree.ElementTree als ET

U moet NumPy installeren, een bibliotheek waarmee u bepaalde vectorbewerkingen zoals een dotproduct en een crossproduct kunt uitvoeren.

3. De Math van Perspectiefprojectie

Het projecteren van een punt in een driedimensionale ruimte naar een 2D-afbeelding omvat het vinden van een vector van de camera tot het punt en het vervolgens splitsen van die vector in drie loodrechte vectoren.. 

De twee gedeeltelijke vectoren loodrecht op de cameravector (de richting waarin de camera kijkt) worden de X en Y coördinaten van een orthogonaal geprojecteerd beeld. De gedeeltelijke vector die parallel loopt aan de cameravector, wordt de naam z afstand van het punt. Als u een orthogonale afbeelding naar een afbeelding in perspectief wilt converteren, deelt u deze X en Y coördineren door de z afstand.

Op dit punt is het logisch bepaalde cameraparameters te definiëren. Ten eerste moeten we weten waar de camera zich in de 3D-ruimte bevindt. Bewaar zijn X, Y, en z coördinaten in een woordenboek.

camera = 'x': -15, 'y': 15, 'z': 30

De globe bevindt zich aan de oorsprong, dus is het logisch om de camera naar de kant te richten. Dat betekent dat de camerarichtingvector het tegenovergestelde is van de camerapositie.

cameraForward = 'x': -1 * camera ['x'], 'y': -1 * camera ['y'], 'z': -1 * camera ['z']

Het is niet alleen voldoende om te bepalen met welke richting de camera wordt geconfronteerd, u moet ook een rotatie voor de camera vastzetten. Doe dat door een vector loodrecht op de te definiëren cameraForward vector.

cameraPerpendicular = 'x': cameraForward ['y'], 'y': -1 * cameraForward ['x'], 'z': 0

1. Definieer nuttige vectorfuncties

Het zal erg handig zijn om bepaalde vectorfuncties in ons programma te definiëren. Definieer een vector magnitude-functie:

#magnitude van een 3D vector def sumOfSquares (vector): return vector ['x'] ** 2 + vector ['y'] ** 2 + vector ['z'] ** 2 def magnitude (vector): return math .sqrt (sumOfSquares (vector))

We moeten in staat zijn om de ene vector op de andere te projecteren. Omdat deze bewerking een puntproduct betreft, is het veel gemakkelijker om de NumPy-bibliotheek te gebruiken. NumPy neemt echter vectoren in lijstvorm, zonder de expliciete 'x', 'y', 'z'-identificatoren, dus we hebben een functie nodig om onze vectoren om te zetten in NumPy-vectoren.

#converteert woordenboekvector om vectordef vectorToList (vector) weer te geven: return [vector ['x'], vector ['y'], vector ['z']]
#projects u op v def vectorProject (u, v): return np.dot (vectorToList (v), vectorToList (u)) / magnitude (v)

Het is fijn om een ​​functie te hebben die ons een eenheidsvector geeft in de richting van een gegeven vector:

#get unit vector def unitVector (vector): magVector = magnitude (vector) return 'x': vector ['x'] / magVector, 'y': vector ['y'] / magVector, 'z': vector [ 'z'] / magVector

Ten slotte moeten we twee punten kunnen nemen en een vector kunnen vinden tussen hen:

# Berekent vector van twee punten, woordenboekvorm def findVector (oorsprong, punt): return 'x': punt ['x'] - oorsprong ['x'], 'y': punt ['y'] - oorsprong [ 'y'], 'z': punt ['z'] - oorsprong ['z']

2. Definieer Camera-assen

Nu moeten we gewoon de camera-assen definiëren. We hebben al twee van deze assen-cameraForward en cameraPerpendicular, overeenkomend met de z afstand en X coördinaat van de afbeelding van de camera. 

Nu hebben we alleen de derde as nodig, gedefinieerd door een vector die de Y coördinaat van de afbeelding van de camera. We kunnen deze derde as vinden door het crossproduct van die twee vectoren te nemen, met behulp van NumPy-np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)).

Het eerste element in het resultaat komt overeen met de X component; de tweede tot de Y component en de derde tot de z component, dus de geproduceerde vector wordt gegeven door:

#Calculates horizon plane vector (wijst naar boven) cameraHorizon = 'x': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)) [0], 'y': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular )) [1], 'z': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)) [2]

3. Projecteer op orthogonaal

Om het orthogonale te vinden X, Y, en z afstand vinden we eerst de vector die de camera en het punt in kwestie met elkaar verbindt en projecteer het vervolgens op elk van de drie eerder gedefinieerde camera-assen:

def physProjection (punt): pointVector = findVector (camera, punt) #pointVector is een vector die begint bij de camera en eindigt op een punt in kwestie return 'x': vectorProject (pointVector, cameraPerpendicular), 'y': vectorProject (pointVector , cameraHorizon), 'z': vectorProject (pointVector, cameraForward)

Een punt (donkergrijs) dat wordt geprojecteerd op de drie camera-assen (grijs). X is rood, Y is groen en z is blauw.

4. Project naar perspectief

Perspectiefprojectie neemt eenvoudig de X en Y van de orthogonale projectie, en verdeelt elke coördinaat door de z afstand. Dit maakt het zo dat dingen die verder weg lijken kleiner lijken dan dingen die dichter bij de camera staan. 

Omdat delen door z levert zeer kleine coördinaten op, we vermenigvuldigen elke coördinaat met een waarde die overeenkomt met de brandpuntsafstand van de camera.

focalLength = 1000
# tekent punten op camerasensor met behulp van xDistance, yDistance en zDistance def perspectiveProjection (pCoords): scaleFactor = focalLength / pCoords ['z'] return 'x': pCoords ['x'] * scaleFactor, 'y': pCoords [ 'y'] * scaleFactor

5. Converteer sferische coördinaten naar rechthoekige coördinaten

De aarde is een bol. Onze coördinaten - lengte- en breedtegraad - zijn dus bolvormige coördinaten. We moeten dus een functie schrijven die sferische coördinaten converteert naar rechthoekige coördinaten (evenals een straal van de aarde definiëren en de π constante):

radius = 10 pi = 3,14159
#vertegenwoordigt sferische coördinaten in rechthoekige coördinaten def sphereToRect (r, a, b): return 'x': r * math.sin (b * pi / 180) * math.cos (a * pi / 180), 'y' : r * math.sin (b * pi / 180) * math.sin (a * pi / 180), 'z': r * math.cos (b * pi / 180)

We kunnen betere prestaties bereiken door enkele berekeningen die meer dan één keer zijn gebruikt op te slaan:

#converts sferische coördinaten in rechthoekige coördinaten def sphereToRect (r, a, b): aRad = math.radians (a) bRad = math.radians (b) r_sin_b = r * math.sin (bRad) return 'x': r_sin_b * math.cos (aRad), 'y': r_sin_b * math.sin (aRad), 'z': r * math.cos (bRad)

We kunnen een aantal samengestelde functies schrijven die alle vorige stappen in één functie combineren - rechtstreeks van sferische of rechthoekige coördinaten naar perspectiefafbeeldingen:

#functies voor het plotten van punten def rectPlot (coördinaat): return perspectiveProjection (physicalProjection (coordinate)) def spherePlot (coordinate, sRadius): return rectPlot (sphereToRect (sRadius, coordinate ['long'], coordinate ['lat']))

4. Weergave naar SVG

Ons script moet in staat zijn om naar een SVG-bestand te schrijven. Dus het zou moeten beginnen met:

f = open ('globe.svg', 'w') f.write ('\ n\ N ")

En eindig met:

f.write ('')

Produceren van een leeg maar geldig SVG-bestand. Binnen dat bestand moet het script SVG-objecten kunnen maken, dus we zullen twee functies definiëren waarmee SVG-punten en polygonen kunnen worden getekend:

# Draait SVG-cirkelobject def svgCircle (coördinaat, cirkelRadius, kleur): f.write ('\ n ') #Teken SVG-polygoonknooppunt def polyNode (coördinaat): f.write (str (coördinaat [' x '] + 400) +', '+ str (coördinaat [' y '] + 400) + ")

We kunnen dit uittesten door een sferisch raster van punten te maken:

#RAK GRID voor x in bereik (72): voor y in bereik (36): svgCircle (spherePlot ('long': 5 * x, 'lat': 5 ​​* y, radius), 1, '#ccc' )

Dit script, wanneer het wordt opgeslagen en uitgevoerd, zou iets als dit moeten produceren:


5. Transformeer de SVG-kaartgegevens

Om een ​​SVG-bestand te kunnen lezen, moet een script een XML-bestand kunnen lezen, omdat SVG een XML-type is. Dat is waarom we hebben geïmporteerd xml.etree.ElementTree. Met deze module kunt u de XML / SVG in een script laden als een geneste lijst:

tree = ET.parse ('BlankMap Equirectangular states.svg') root = tree.getroot ()

U kunt navigeren naar een object in de SVG via de lijstindexen (meestal moet u de broncode van het kaartbestand bekijken om de structuur ervan te begrijpen). In ons geval bevindt elk land zich op wortel [4] [0] [X] [n], waar X is het nummer van het land, beginnend met 1, en n vertegenwoordigt de verschillende subpaden die het land beschrijven. De werkelijke contouren van het land worden opgeslagen in de d kenmerk, toegankelijk via wortel [4] [0] [X] [n] .Attrib [ 'd'].

1. Construeer lussen

We kunnen deze kaart niet zomaar herhalen, omdat deze in het begin een "dummy" -element bevat dat moet worden overgeslagen. Dus we moeten het aantal "land" -objecten tellen en er een aftrekken om de dummy kwijt te raken. Vervolgens doorlopen we de resterende objecten.

countries = len (root [4] [0]) - 1 voor x binnen bereik (landen): root [4] [0] [x + 1]

Sommige landobjecten bevatten meerdere paden. Daarom herhalen we elk pad in elk land:

countries = len (root [4] [0]) - 1 voor x binnen bereik (landen): voor pad in root [4] [0] [x + 1]:

Binnen elk pad zijn er disjuncte contouren gescheiden door de tekens 'Z M' in de d string, dus hebben we de d teken langs dat scheidingsteken en doorheen herhalen die.

countries = len (root [4] [0]) - 1 voor x binnen bereik (landen): voor pad in root [4] [0] [x + 1]: voor k in re.split ('Z M', path.attrib [ 'd']):

Vervolgens splitsen we elke contour door de scheidingstekens 'Z', 'L' of 'M' om de coördinaat van elk punt in het pad te krijgen:

voor x in bereik (landen): voor pad in root [4] [0] [x + 1]: voor k in re.split ('Z M', path.attrib ['d']): voor i in re .split ('Z | M | L', k):

Vervolgens verwijderen we alle niet-numerieke tekens uit de coördinaten en splitsen ze in tweeën langs de komma's, met de breedtegraden en lengtegraden. Als beide bestaan, slaan we ze op in een sphereCoordinates woordenboek (op de kaart gaan de coördinaten van de breedtegraad van 0 tot 180 °, maar we willen dat ze gaan van -90 ° tot 90 ° -noord en zuidelijk, dus we trekken 90 ° af).

voor x in bereik (landen): voor pad in root [4] [0] [x + 1]: voor k in re.split ('Z M', path.attrib ['d']): voor i in re .split ('Z | M | L', k): breakup = re.split (',', re.sub ("[^ - 0123456789.,]", "", i)) als breakup [0] en uiteenvallen [1]: sphereCoordinates =  sphereCoordinates ['long'] = float (breakup [0]) sphereCoordinates ['lat'] = float (break-up [1]) - 90

Dan als we het uittesten door enkele punten uit te zetten (svgCircle (spherePlot (sphereCoordinates, radius), 1, '# 333')), krijgen we zoiets als dit:

2. Oplossen voor occlusie

Dit maakt geen onderscheid tussen punten aan de nabije kant van de wereld en punten aan de andere kant van de aardbol. Als we alleen maar punten op de zichtbare kant van de planeet willen afdrukken, moeten we kunnen uitvinden aan welke kant van de planeet een bepaald punt is. 

We kunnen dit doen door de twee punten op de bol te berekenen waarbij een straal van de camera tot het punt de bol zou kruisen. Deze functie implementeert de formule voor het oplossen van de afstanden tot die twee punten-dNear en DFAR:

cameraDistanceSquare = sumOfSquares (camera) #afstand van globe center tot camera def distanceToPoint (spherePoint): point = sphereToRect (radius, spherePoint ['long'], spherePoint ['lat']) ray = findVector (camera, punt) returnvectorProject ( ray, cameraForward)
def occlude (spherePoint): point = sphereToRect (radius, spherePoint ['long'], spherePoint ['lat']) ray = findVector (camera, punt) d1 = magnitude (straal) #afstand van camera tot punt dot_l = np. punt ([straalpijp ['x'] / d1, straal ['y'] / d1, straal ['z'] / d1], vectorToList (camera)) #dot product van eenheidsvector van camera naar punt en camera vectordeterminant = math.sqrt (abs ((dot_l) ** 2 - cameraDistanceSquare + radius ** 2)) dNear = - (dot_l) + determinant dFar = - (punt_l) - bepalend

Als de werkelijke afstand tot het punt, d1, is kleiner dan of gelijk aan beide van deze afstanden, dan is het punt aan de dichtstbijzijnde kant van de bol. Vanwege afrondingsfouten is een kleine bewegingsruimte ingebouwd in deze bewerking:

 als d1 - 0.0000000001 <= dNear and d1 - 0.0000000001 <= dFar : return True else: return False

Als deze functie als voorwaarde wordt gebruikt, moet de weergave worden beperkt tot near-side points:

 als occlude (sphereCoordinates): svgCircle (spherePlot (sphereCoordinates, radius), 1, '# 333')

6. Geef vaste landen weer

Natuurlijk zijn de puntjes geen echte gesloten, gevulde vormen - ze geven alleen de illusie van gesloten vormen. Het tekenen van echte gevulde landen vereist een beetje meer verfijning. Allereerst moeten we het geheel van alle zichtbare landen afdrukken. 

We kunnen dit doen door een schakelaar te maken die wordt geactiveerd wanneer een land een zichtbaar punt bevat, terwijl het tijdelijk de coördinaten van dat land opslaat. Als de schakelaar is geactiveerd, wordt het land getekend met behulp van de opgeslagen coördinaten. We zullen ook veelhoeken tekenen in plaats van punten.

voor x in bereik (landen): voor pad in root [4] [0] [x + 1]: voor k in re.split ('Z M', path.attrib ['d']): countryIsVisible = False country = [] voor i in re.split ('Z | M | L', k): breakup = re.split (',', re.sub ("[^ - 0123456789.,]", "", i) ) als uiteenvallen [0] en uiteenvallen [1]: sphereCoordinates =  sphereCoordinates ['long'] = float (breakup [0]) sphereCoordinates ['lat'] = float (breakup [1]) - 90 #DRAW COUNTRY if occlude (sphereCoordinates): country.append ([sphereCoordinates, radius]) countryIsVisible = True else: country.append ([sphereCoordinates, radius]) if countryIsVisible: f.write ('\ N \ n ")

Het is moeilijk te zeggen, maar de landen aan de rand van de wereld vouwen in zichzelf, wat we niet willen (kijk eens naar Brazilië).

1. Traceer de schijf van de aarde

Om ervoor te zorgen dat de landen correct worden weergegeven aan de randen van de wereld, moeten we eerst de schijf van de wereld traceren met een polygoon (de schijf die u in de punten ziet is een optische illusie). De schijf wordt geschetst door de zichtbare rand van de bol - een cirkel. De volgende bewerkingen berekenen de straal en het middelpunt van deze cirkel, evenals de afstand van het vlak dat de cirkel van de camera bevat, en het midden van de wereld..

#TRACE LIMB limbRadius = math.sqrt (straal ** 2 - straal ** 4 / cameraAfstandSquare) cx = camera ['x'] * straal ** 2 / cameraAfstandSquare cy = camera ['y'] * straal ** 2 / cameraDistanceSquare cz = camera ['z'] * straal ** 2 / cameraAfstandSquare planeDistance = magnitude (camera) * (1 - straal ** 2 / cameraDistanceSquare) planeDisplacement = math.sqrt (cx ** 2 + cy ** 2 + cz ** 2)

De aarde en camera (donkergrijs punt) van boven gezien. De roze lijn vertegenwoordigt de zichtbare rand van de aarde. Alleen de gearceerde sector is zichtbaar voor de camera.

Om vervolgens een cirkel in dat vlak te tekenen, construeren we twee assen parallel aan dat vlak:

#trade & negeer x en y om een ​​loodrechte vectoreenheid te krijgenVectorCamera = unitVector (camera) aV = unitVector ('x': -unitVectorCamera ['y'], 'y': unitVectorCamera ['x'], 'z': 0) bV = np.cross (vectorToList (aV), vectorToList (unitVectorCamera))

Vervolgens plotten we alleen op die assen met stappen van 2 graden om een ​​cirkel in dat vlak met die straal en middelpunt uit te zetten (zie deze uitleg voor de berekening):

voor t in bereik (180): theta = math.radians (2 * t) cosT = math.cos (theta) sinT = math.sin (theta) limbPoint = 'x': cx + limbRadius * (cosT * aV [ 'x'] + sinT * bV [0]), 'y': cy + limbRadius * (cosT * aV ['y'] + sinT * bV [1]), 'z': cz + limbRadius * (cosT * aV ['z'] + sinT * bV [2])

Dan kapselen we dat allemaal in met tekencode voor veelhoeken:

f.write ('')

We maken ook een kopie van dat object om later te gebruiken als knipmasker voor al onze landen:

f.write ('')

Dat zou je dit moeten geven:

2. Clipping to the Disk

Met behulp van de nieuw berekende schijf kunnen we onze wijzigen anders verklaring in de landcode (voor wanneer coördinaten zich aan de verborgen kant van de wereld bevinden) om die punten ergens buiten de schijf te plotten:

 else: tangentscale = (radius + planeDisplacement) / (pi * 0.5) rr = 1 + abs (math.tan ((distanceToPoint (sphereCoordinates) - planeDistance) / tangentscale)) country.append ([sphereCoordinates, radius * rr])

Dit gebruikt een raaklijn om de verborgen punten boven het aardoppervlak op te tillen, waardoor het lijkt alsof ze eromheen zijn uitgespreid:

Dit is niet helemaal wiskundig verantwoord (het valt uiteen als de camera niet ruw in het midden van de planeet wordt gericht), maar het is eenvoudig en werkt meestal. Door simpelweg toe te voegen clip-path = "URL (#clipglobe)" aan de veelhoektekeningcode, kunnen we de landen netjes op de rand van de wereld zetten:

 if countryIsVisible: f.write ('

Ik hoop dat je deze tutorial leuk vond! Veel plezier met je vector globes!