Gi en SVG Globe

Hva du skal skape

I denne opplæringen vil jeg vise deg hvordan du tar et SVG-kart og projiserer det på en verden, som en vektor. For å utføre de matematiske transformasjonene som trengs for å projisere kartet på en sfære, må vi bruke Python-skripting til å lese kartdataene og oversette det til et bilde av en klokke. Denne opplæringen antar at du kjører Python 3.4, den siste tilgjengelige Python.

Inkscape har en slags Python API som kan brukes til å gjøre en rekke ting. Men siden vi bare er interessert i å transformere former, er det lettere å bare skrive et frittstående program som leser og skriver ut SVG-filer på egen hånd.

1. Formater kartet

Den type kart vi ønsker er kalt et equirectangular kart. I et equirectangular kart svarer lengden og bredden til et sted til dens x og y posisjon på kartet. Et equirectangular verdenskart finnes på Wikimedia Commons (her er en versjon med amerikanske stater).

SVG-koordinater kan defineres på flere måter. For eksempel kan de være i forhold til det tidligere definerte punktet, eller definert helt fra opprinnelsen. For å gjøre livet enklere, ønsker vi å konvertere koordinatene i kartet til absolutt skjema. Inkscape kan gjøre dette. Gå til Inkscape-preferanser (under Redigere meny) og under Input / Output > SVG Output, sett Banestrengformat til Absolute.

Inkscape konverterer ikke automatisk koordinatene automatisk; du må utføre en slags forvandling på stiene for å få det til å skje. Den enkleste måten å gjøre det på er å bare velge alt og flytte det opp og ned med et trykk på hver av opp- og nedpilene. Legg deretter på filen på nytt.

2. Start Python Script

Opprett en ny Python-fil. Importer følgende moduler:

import sys import re import matte importtid import datetime import numpy som np import xml.etree.ElementTree som ET

Du må installere NumPy, et bibliotek som lar deg gjøre visse vektoroperasjoner som punktprodukt og kryssprodukt.

3. Matematikk av perspektivprojeksjon

Projisere et punkt i tredimensjonalt mellomrom i et 2D-bilde innebærer å finne en vektor fra kameraet til punktet, og deretter splitte den vektoren inn i tre vinkelrette vektorer. 

De to delvektorer som er vinkelrett på kameraviktoren (retningen kameraet vender mot) blir x og y koordinater for et ortogonalt projisert bilde. Den partielle vektoren som er parallell med kameravektoren, blir noe som kalles z avstanden til punktet. For å konvertere et ortogonalt bilde til et perspektivbilde, divisjon hver x og y koordinere av z avstand.

På dette punktet er det fornuftig å definere bestemte kameraparametere. Først må vi vite hvor kameraet ligger i 3D-plass. Lagre det x, y, og z koordinater i en ordbok.

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

Kloden vil være lokalisert ved opprinnelsen, så det er fornuftig å orientere kameraet mot den. Det betyr at kameraretningsvektoren vil være motsatt av kameraposisjonen.

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

Det er ikke bare nok til å bestemme hvilken retning kameraet står overfor - du må også spikre ned en rotasjon for kameraet. Gjør det ved å definere en vektor vinkelrett på cameraForward vektor.

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

1. Definer nyttige vektorfunksjoner

Det vil være svært nyttig å ha bestemte vektorfunksjoner som er definert i vårt program. Definer en vektorstørrelsesfunksjon:

#magnet av en 3D vektor def sumOfSquares (vektor): returvektor ['x'] ** 2 + vektor ['y'] ** 2 + vektor ['z'] ** 2 def magnitude (vektor): retur matte .sqrt (sumOfSquares (vektor))

Vi må være i stand til å projisere en vektor på en annen. Fordi denne operasjonen innebærer et punktprodukt, er det mye enklere å bruke NumPy-biblioteket. NumPy tar imidlertid vektorer i listeform, uten de eksplisitte "x", "y", "z" -identifikatorene, så vi trenger en funksjon for å konvertere vektorer til NumPy-vektorer.

#converts ordbok vektor til liste vektor def vectorToList (vektor): retur [vektor ['x'], vektor ['y'], vektor ['z']]
#projects deg på v def vectorProject (u, v): returnere np.dot (vectorToList (v), vectorToList (u)) / magnitude (v)

Det er hyggelig å ha en funksjon som gir oss en enhedsvektor i retning av en gitt vektor:

#ve unit vector def unitVector (vector): magVector = magnitude (vector) retur 'x': vector ['x'] / magVector, 'y': vektor ['y'] / magVector, 'z': vektor [ 'z'] / magVector

Til slutt må vi kunne ta to poeng og finne en vektor mellom dem:

#Kalkulerer vektor fra to punkter, ordbok skjema def findVector (opprinnelse, punkt): retur 'x': punkt ['x'] - opprinnelse ['x'], 'y': punkt ['y'] - opprinnelse [ 'y'], 'z': punkt ['z'] - opprinnelse ['z']

2. Definer kameraakser

Nå trenger vi bare å fullføre definisjon av kameraaksene. Vi har allerede to av disse aksene-cameraForward og cameraPerpendicular, svarende til z avstand og x koordinere kameraets bilde. 

Nå trenger vi bare den tredje aksen, definert av en vektor som representerer y koordinere kameraets bilde. Vi kan finne denne tredje aksen ved å ta kryssproduktet av de to vektorene, ved hjelp av NumPy-np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)).

Det første elementet i resultatet tilsvarer x komponent; den andre til y komponent, og den tredje til z komponent, slik at vektoren produsert er gitt av:

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

3. Prosjekt til ortogonale

For å finne ortogonalen x, y, og z avstand, finner vi først vektoren som kobler kameraet og punktet i spørsmålet, og deretter prosjekterer det på hver av de tre kameraaksene som er definert tidligere:

def physicalProjection (point): pointVector = findVector (kamera, punkt) #pointVector er en vektor som starter fra kameraet og slutter ved et punkt i spørsmålet returnerer 'x': vectorProject (pointVector, cameraPerpendicular), 'y': vectorProject , cameraHorizon), 'z': vectorProject (pointVector, kameraForward)

Et punkt (mørkegrå) blir projisert på de tre kameraaksene (grå). x er rød, y er grønn og z er blå.

4. Prosjekt til perspektiv

Perspektivprojeksjon tar bare x og y av det ortogonale fremspringet, og deler hver koordinat av z avstand. Dette gjør at ting som er lengre unna ser mindre ut enn ting som er nærmere kameraet. 

Fordi dele med z gir svært små koordinater, vi multipliserer hver koordinat med en verdi som tilsvarer brennvidden til kameraet.

fokallengde = 1000
# tegner poeng på kamerasensor ved hjelp av xDistance, yDistance og zDistance def perspektivProjection (pCoords): scaleFactor = focalLength / pCoords ['z'] retur 'x': pCoords ['x'] * skalaFaktor, 'y': pCoords [ 'y'] * skalafaktor

5. Konverter sfæriske koordinater til rektangulære koordinater

Jorden er en sfære. Dermed er våre koordinater-breddegrad og lengdegrad-sfæriske koordinater. Så vi må skrive en funksjon som konverterer sfæriske koordinater til rektangulære koordinater (samt definere en radius av jorden og gi den π konstant):

radius = 10 pi = 3,14159
#konverterer sfæriske koordinater til rektangulære koordinater def sphereToRect (r, a, b): retur '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)

Vi kan oppnå bedre ytelse ved å lagre noen beregninger som brukes mer enn én gang:

#konverterer sfæriske koordinater til rektangulære koordinater def sphereToRect (r, a, b): aRad = math.radians (a) bRad = math.radians (b) r_sin_b = r * math.sin (bRad) retur 'x': r_sin_b * math.cos (aRad), 'y': r_sin_b * math.sin (aRad), 'z': r * math.cos (bRad)

Vi kan skrive noen sammensatte funksjoner som kombinerer alle de forrige trinnene til en funksjon, som går rett fra sfæriske eller rektangulære koordinater til perspektivbilder:

#funksjoner for plotting points def rectPlot (koordinat): returperspektivProjektjon (fysiskProjektjon (koordinat)) def spherePlot (koordinat, sRadius): returnere rectPlot (sphereToRect (sRadius, koordinere ['lang'], koordinere ['lat'])))

4. Rendering til SVG

Skriptet vårt må kunne skrive til en SVG-fil. Så det burde begynne med:

f = åpen ('globe.svg', 'w') f.write ('\ n\ N ')

Og slutte med:

f.write ('')

Produserer en tom, men gyldig SVG-fil. Innenfor filen må skriptet skape SVG-objekter, slik at vi skal definere to funksjoner som gjør det mulig å tegne SVG-punkter og polygoner:

#Draws SVG sirkel objekt def svgCircle (koordinat, sirkelRadius, farge): f.write ('\ n ') #Draws SVG polygon node def polyNode (koordinat): f.write (str (koordinat [' x '] + 400) +', '+ str (koordinat [' y '] + 400) + ")

Vi kan teste dette ut ved å gi et sfærisk rutenett:

#DRAW GRID for x i rekkevidde (72): for y i rekkevidde (36): svgCircle (spherePlot ('lang': 5 * x, 'lat': 5 ​​* y, radius), 1, '#ccc' )

Dette skriptet, når det er lagret og kjørt, burde produsere noe slikt:


5. Forvandle SVG-kartdataene

For å lese en SVG-fil, må et skript kunne lese en XML-fil, siden SVG er en type XML. Derfor importerte vi xml.etree.ElementTree. Denne modulen lar deg laste XML / SVG til et skript som en nestet liste:

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

Du kan navigere til et objekt i SVG gjennom listedataene (vanligvis må du se på kilden til kartfilen for å forstå strukturen). I vårt tilfelle er hvert land lokalisert på rot [4] [0] [x] [n], hvor x er nummeret til landet, som begynner med 1, og n representerer de forskjellige underruter som skisserer landet. Den faktiske konturer av landet er lagret i d attributt, tilgjengelig gjennom rot [4] [0] [x] [n] .Attrib [ 'd'].

1. Konstruer Looper

Vi kan ikke bare gjenta gjennom dette kartet fordi det inneholder et "dummy" -element i begynnelsen som må hoppes over. Så vi må telle antall "land" objekter og trekke en for å bli kvitt dummien. Så løp vi gjennom gjenværende gjenstander.

land = len (root [4] [0]) - 1 for x i rekkevidde (land): root [4] [0] [x + 1]

Noen landsobjekter inneholder flere baner, og det er derfor vi gjenspeiler det gjennom hver bane i hvert land:

land = len (root [4] [0]) - 1 for x i rekkevidde (land): for sti i rot [4] [0] [x + 1]:

Innenfor hver bane er det ujevne konturer skilt av tegnene 'Z M' i d streng, så vi splittet d streng langs den avgrensningen og iterere gjennom de.

land = len (root [4] [0]) - 1 for x i rekkevidde (land): for sti i rot [4] [0] [x + 1]: for k i re.split ('Z M' path.attrib [ 'd']):

Vi deler deretter hver kontur av delimitrene 'Z', 'L' eller 'M' for å få koordinatet til hvert punkt i banen:

for x i rekkevidde (land): for sti i rot [4] [0] [x + 1]: for k i re.split ('Z M', path.attrib ['d']): for jeg er i .split ('Z | M | L', k):

Da fjerner vi alle ikke-numeriske tegn fra koordinatene og deler dem halvveis langs kommaene, og gir breddegrader og lengder. Hvis begge eksisterer, lagrer vi dem i en sphereCoordinates ordliste (i kart, latitudkoordinater går fra 0 til 180 °, men vi vil at de skal gå fra -90 ° til 90 ° -nord og sør-så vi trekker 90 °).

for x i rekkevidde (land): for sti i rot [4] [0] [x + 1]: for k i re.split ('Z M', path.attrib ['d']): for jeg er i .split ('Z | M | L', k): breakup = re.split (',' re.sub ("[^ - 0123456789.,]", "", i)) ved oppbrudd [0] og breakup [1]: sphereCoordinates =  sphereCoordinates ['long'] = float (oppbrudd [0]) sphereCoordinates ['lat'] = float (breakup [1]) - 90

Så hvis vi teste det ut ved å plotte noen poeng (svgCircle (sfærePlot (sfæreKoordinater, radius), 1, '# 333')), får vi noe slikt:

2. Løs for okklusjon

Dette skiller ikke mellom poeng på nærsiden av kloden og peker på den andre siden av kloden. Hvis vi bare vil skrive ut prikker på den synlige siden av planeten, må vi kunne finne ut hvilken side av planeten et gitt punkt er på. 

Vi kan gjøre dette ved å beregne de to punktene på sfæren hvor en stråle fra kameraet til punktet ville krysse med sfæren. Denne funksjonen implementerer formelen for å løse avstandene til de to punktene-dNear og DFAR:

cameraDistanceSquare = sumOfSquares (kamera) #distance fra klokkesenter til kamera def distanceToPoint (spherePoint): punkt = sphereToRect (radius, spherePoint ['lang'], spherePoint ['lat']) ray = findVector (kamera, punkt) returvektorProsjekt ray, kameraForward)
def occlude (spherePoint): punkt = sphereToRect (radius, spherePoint ['long'], spherePoint ['lat']) ray = findVector (kamera, punkt) d1 = magnitude (ray) #distance fra kamera til punkt dot_l = np. dot (ray ['x'] / d1, ray ['y'] / d1, ray ['z'] / d1], vectorToList (kamera)) #dot produkt av enhetsvektor fra kamera til punkt og kameravektoren = math.sqrt (abs ((dot_l) ** 2 - cameraDistanceSquare + radius ** 2)) dNear = - (dot_l) + determinant dFar = - (dot_l) - determinant

Hvis den faktiske avstanden til punktet, d1, er mindre enn eller lik både av disse avstandene, så er punktet på kantenes nærside. På grunn av avrundingsfeil er et lite wiggle-rom bygd inn i denne operasjonen:

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

Bruk av denne funksjonen som en betingelse, bør begrense gjengivelsen til steder i nærheten:

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

6. Gjenvinne faste land

Selvfølgelig er prikkene ikke sanne lukkede, fylte former - de gir bare illusjonen av lukkede figurer. Tegning faktiske fylt land krever litt mer raffinement. Først og fremst må vi skrive ut hele alle synlige land. 

Vi kan gjøre det ved å opprette en bryter som aktiveres når et land inneholder et synlig punkt, samtidig som det midlertidig lagres koordinatene til landet. Hvis bryteren er aktivert, blir landet trukket med de lagrede koordinatene. Vi vil også tegne polygoner i stedet for poeng.

for x i rekkevidde (land): for sti i rot [4] [0] [x + 1]: for k i re.split ('Z M', path.attrib ['d']): countryIsVisible = Falsk land = [] for jeg i re.split ('Z | M | L', k): breakup = re.split (',', re.sub ("[^ - 0123456789.,]", "", i) ) hvis oppbrudd [0] og breakup [1]: sphereCoordinates =  sphereCoordinates ['long'] = float (breakup [0]) sphereCoordinates ['lat'] = float (breakup [1]) - 90 #DRAW COUNTRY if occlude (sphereCoordinates): country.append ([sfæreKoordinater, radius]) countryIsVisible = True else: country.append ([sfæreKoordinater, radius]) hvis countryIsVisible: f.write ('\ N \ n ")

Det er vanskelig å fortelle, men landene på kanten av kloden bretter seg inn på seg selv, som vi ikke vil ha (ta en titt på Brasil).

1. Spor jordens disk

For å få landene til å gjengis riktig på verdens kant, må vi først spore jordens plate med en polygon (disken du ser fra punktene er en optisk illusjon). Disken er skissert av den synlige kanten av kloden - en sirkel. Følgende operasjoner beregner radius og senter for denne sirkelen, samt avstanden til flyet som inneholder sirkelen fra kameraet og sentrum av kloden.

#TRACE LIMB limbRadius = math.sqrt (radius ** 2 - radius ** 4 / kameraDistanceSquare) cx = kamera ['x'] * radius ** 2 / kameraDistanceSquare cy = kamera ['y'] * radius ** 2 / cameraDistanceSquare cz = kamera ['z'] * radius ** 2 / kameraDistanceSquare planetDistance = magnitude (kamera) * (1 - radius ** 2 / cameraDistanceSquare) planetDisplacement = math.sqrt (cx ** 2 + cy ** 2 + cz ** 2)

Jorden og kameraet (mørkegråt punkt) sett ovenfra. Den rosa linjen representerer jordens synlige kant. Bare den skyggefulle sektoren er synlig for kameraet.

Så å tegne en sirkel i det planet, konstruerer vi to akser parallelt med det flyet:

#trade & negate x og y for å få en vinkelrett vektor unitVectorCamera = unitVector (kamera) aV = unitVector ('x': -unitVectorCamera ['y'], 'y': unitVectorCamera ['x'], 'z': 0) bV = np.cross (vectorToList (aV), vectorToList (unitVectorCamera))

Da graver vi bare på disse aksene med trinn på 2 grader for å plotte en sirkel i det flyet med radius og senter (se denne forklaringen for matematikken):

for t i rekkevidde (180): theta = math.radians (2 * t) cosT = math.cos (theta) sinT = math.sin (theta) limbPoint = 'x': cx + limbRadius * (cosT * aV [ 'z': cz + limbRadius * (cosT * aV ['y'] + sinT * bV [1]), 'z': cz + limbRadius * aV ['z'] + sinT * bV [2])

Så innkapsler vi alt sammen med polygon tegningskode:

f.write ('')

Vi lager også en kopi av det aktuelle objektet for senere bruk som utklippsmaske for alle våre land:

f.write ('')

Det burde gi deg dette:

2. Klipping til disken

Ved å bruke den nylig beregnede disken, kan vi endre vår ellers uttalelse i landplottingskoden (for når koordinatene er på skjult side av kloden) for å plotte disse punktene et sted utenfor disken:

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

Dette bruker en tangentkurve for å løfte de skjulte punktene over jordens overflate, noe som gir utseendet at de er spredt rundt det:

Dette er ikke helt matematisk lyd (det bryter ned hvis kameraet ikke er grovt spiss midt på planeten), men det er enkelt og fungerer mesteparten av tiden. Deretter bare ved å legge til clip-path = "url (#clipglobe)" til polygon tegningskoden, kan vi pent klippe landene til kanten av kloden:

 hvis countryIsVisible: f.write ('

Jeg håper du likte denne opplæringen! Ha det gøy med vektorkjærene dine!