I denna handledning visar jag dig hur man tar en SVG-karta och projekterar den på en jordklot som en vektor. För att utföra de matematiska omvandlingar som behövs för att kartlägga kartan på en sfär, måste vi använda Python-skript för att läsa kartdata och översätta den till en bild av en jordklot. Denna handledning förutsätter att du kör Python 3.4, den senaste tillgängliga Python.
Inkscape har någon form av Python API som kan användas för att göra en mängd olika saker. Men eftersom vi bara är intresserade av att forma former, är det lättare att bara skriva ett fristående program som läser och skriver ut SVG-filer i sig.
Den typ av karta som vi vill kallas en equirectangular karta. I en equirectangular karta motsvarar longituden och latituden för en plats dens x och y position på kartan. En equirectangular världskarta finns på Wikimedia Commons (här är en version med USA-stater).
SVG-koordinater kan definieras på olika sätt. De kan till exempel vara i förhållande till den tidigare definierade punkten, eller definieras absolut från ursprunget. För att göra våra liv enklare, vill vi konvertera koordinaterna i kartan till den absoluta formen. Inkscape kan göra detta. Gå till Inkscape-inställningar (under Redigera menyn) och under Ingång / utgång > SVG-utgång, uppsättning Stråkformat till Absolut.
Inkscape konverterar inte automatiskt koordinaterna; du måste utföra någon form av omvandling på banorna för att få det att hända. Det enklaste sättet att göra det är bara att välja allt och flytta upp det och gå ner med ett tryck på var och en av upp- och nedåtpilarna. Spara sedan filen igen.
Skapa en ny Python-fil. Importera följande moduler:
import sys import re import matte importtid import datetime import numpy som np import xml.etree.ElementTree som ET
Du måste installera NumPy, ett bibliotek som låter dig göra vissa vektoroperationer som punktprodukt och korsprodukt.
Att projicera en punkt i tredimensionellt utrymme i en 2D-bild innebär att man hittar en vektor från kameran till punkten och splittrar sedan vektorn i tre vinkelräta vektorer.
De två delvektorerna vinkelrätt mot kamerafiguren (den riktning kameran står inför) blir x och y koordinater för en ortogonalt projicerad bild. Den partiella vektorn parallell med kamerans vektor blir något som kallas z punktens avstånd. För att konvertera en ortogonal bild till en perspektivbild, dela upp varje x och y koordinera av z distans.
Vid denna punkt är det meningsfullt att definiera vissa kameraparametrar. Först måste vi veta var kameran ligger i 3D-utrymme. Förvara dess x, y, och z koordinater i en ordbok.
kamera = 'x': -15, 'y': 15, 'z': 30
Jordklotet kommer att vara beläget vid ursprunget, så det är vettigt att rikta kameran mot den. Det betyder att kamerriktningsvektorn kommer att vara motsatt av kamerans position.
cameraForward = 'x': -1 * kamera ['x'], 'y': -1 * kamera ['y'], 'z': -1 * kamera ['z']
Det är inte bara tillräckligt för att bestämma vilken riktning kameran står inför - du måste också spika ner en rotation för kameran. Gör det genom att definiera en vektor vinkelrätt mot cameraForward
vektor.
cameraPerpendicular = 'x': cameraForward ['y'], 'y': -1 * cameraForward ['x'], 'z': 0
Det kommer att vara till stor hjälp att ha vissa vektorfunktioner som definieras i vårt program. Definiera en vektor magnitud funktion:
#magnet av en 3D vektor def sumOfSquares (vektor): returvektor ['x'] ** 2 + vektor ['y'] ** 2 + vektor ['z'] ** 2 def magnitude (vektor): returnera matte .sqrt (sumOfSquares (vektor))
Vi måste kunna projekta en vektor på en annan. Eftersom denna operation innebär en prickprodukt är det mycket lättare att använda NumPy-biblioteket. NumPy tar emellertid vektorer i listform, utan de explicit "x", "y", "z" -identifierarna, så vi behöver en funktion för att konvertera våra vektorer till NumPy-vektorer.
#converts ordbok vektor för att lista vektor def vectorToList (vektor): returnera [vektor ['x'], vektor ['y'], vektor ['z']]
#projects u på v def vectorProject (u, v): returnera np.dot (vectorToList (v), vectorToList (u)) / magnitude (v)
Det är trevligt att ha en funktion som ger oss en enhetsvektor i riktning mot en given vektor:
#ve enhet vektor def unitVector (vector): magVector = magnitude (vector) returnera 'x': vektor ['x'] / magVector, 'y': vektor ['y'] / magVector, 'z': vektor [ 'z'] / magVector
Slutligen måste vi kunna ta två punkter och hitta en vektor mellan dem:
#Räknar vektor från två punkter, ordboksform def findVector (ursprung, punkt): returnera 'x': punkt ['x'] - ursprung ['x'], 'y': punkt ['y'] - 'y'], 'z': punkt ['z'] - ursprung ['z']
Nu behöver vi bara slutföra att definiera kamerans axlar. Vi har redan två av dessa axlar-cameraForward
och cameraPerpendicular
, motsvarar z avstånd och x koordinat av kamerans bild.
Nu behöver vi bara den tredje axeln, definierad av en vektor som representerar y koordinat av kamerans bild. Vi kan hitta denna tredje axel genom att ta korsprodukten av de två vektorerna, med hjälp av NumPy-np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular))
.
Det första elementet i resultatet motsvarar x komponent; den andra till y komponent och den tredje till z komponent, så den producerade vektorn ges av:
#Calculates horizon plane vector (poäng uppåt) cameraHorizon = 'x': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)) [0], 'y': np.cross (vectorToList (cameraForward), vectorToList )) [1], 'z': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)) [2]
Att hitta ortogonalen x, y, och z avstånd, hittar vi först vektorn som kopplar kameran och punkten i fråga och sedan projekterar den på var och en av de tre tidigare bildade kamerans axlar:
def physicalProjection (point): pointVector = findVector (kamera, punkt) #pointVector är en vektor som börjar från kameran och slutar vid en punkt i fråga returnerar 'x': vectorProject (pointVector, cameraPerpendicular), 'y': vectorProject (pointVector , cameraHorizon), 'z': vectorProject (pointVector, cameraForward)
En punkt (mörkgrå) projiceras på de tre kameraaxlarna (grå). x är röd, y är grön och z är blå.
Perspektivprojektion tar helt enkelt x och y av den ortogonala utsprånget, och delar varje koordinat av z distans. Det gör det så att saker som är längre bort ser mindre ut än saker som är närmare kameran.
Eftersom dela med z ger mycket små koordinater, vi multiplicerar varje koordinat med ett värde som motsvarar kamerans brännvidd.
focalLength = 1000
# pekar på kamerans sensor med xDistance, yDistance och zDistance def perspectiveProjection (pCoords): scaleFactor = focalLength / pCoords ['z'] retur 'x': pCoords ['x'] * scaleFactor, 'y': pCoords [ 'y'] * skalfaktor
Jorden är en sfär. Således är våra koordinater - latitud och longitud - sfäriska koordinater. Så vi måste skriva en funktion som omvandlar sfäriska koordinater till rektangulära koordinater (såväl som definierar en radie av jorden och ger den π konstant):
radie = 10 pi = 3,14159
#converts sfäriska koordinater till rektangulära koordinater def sphereToRect (r, a, b): returnera '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 uppnå bättre prestanda genom att lagra några beräkningar som används mer än en gång:
#konverterar sfäriska koordinater till rektangulära 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 skriva några kompositfunktioner som kombinerar alla föregående steg i en funktion, som går rakt från sfäriska eller rektangulära koordinater till perspektivbilder:
#funktioner för plottningspunkter def rectPlot (koordinat): returperspektivProjection (physicalProjection (koordinat)) def spherePlot (koordinat sRadius): returnera rectPlot (sphereToRect (sRadius, koordinat ['long'], koordinat ['lat'])
Vårt skript måste kunna skriva till en SVG-fil. Så det bör börja med:
f = öppen ('globe.svg', 'w') f.write ('\ n
Och sluta med:
f.write ('')
Producerar en tom men giltig SVG-fil. Inom den filen måste skriptet skapa SVG-objekt, så vi definierar två funktioner som gör det möjligt att dra SVG-punkter och polygoner:
#Draws SVG cirkel objekt def svgCircle (koordinat, circleRadius, färg): f.write ('\ n ') #Draws SVG polygon nod def polyNode (koordinat): f.write (str (koordinat [' x '] + 400) +', '+ str (koordinat [' y '] + 400) + ")
Vi kan testa detta genom att göra ett sfäriskt raster av punkter:
#DRAW GRID för x i intervallet (72): för y inom intervallet (36): svgCircle (sfärPlot ('long': 5 * x, 'lat': 5 * y, radien), 1, '#ccc' )
Detta skript, när det sparas och körs, borde producera något så här:
För att läsa en SVG-fil måste ett skript kunna läsa en XML-fil, eftersom SVG är en typ av XML. Därför importerade vi xml.etree.ElementTree
. Den här modulen låter dig ladda XML / SVG till ett skript som en kapslad lista:
tree = ET.parse ('BlankMap Equirectangular states.svg') root = tree.getroot ()
Du kan navigera till ett objekt i SVG genom listindexen (vanligtvis måste du titta på källkoden för kartfilen för att förstå dess struktur). I vårt fall ligger varje land på root [4] [0] [x] [n]
, var x är landets nummer, från och med 1, och n representerar de olika delområden som skisserar landet. Landets faktiska konturer lagras i d attribut, tillgänglig via root [4] [0] [x] [n] .Attrib [ 'd']
.
Vi kan inte bara iterera genom denna karta eftersom den innehåller ett "dummy" -element i början som måste hoppas över. Så vi måste räkna antalet "land" -objekt och dra av en för att bli av med dummy. Sedan gick vi igenom de återstående föremålen.
länder = len (root [4] [0]) - 1 för x i intervallet (länder): root [4] [0] [x + 1]
Vissa objekt i landet inkluderar flera banor, varför vi sedan repeterar genom varje bana i varje land:
länder = len (root [4] [0]) - 1 för x i intervallet (länder): för sökvägen i rot [4] [0] [x + 1]:
Inom varje väg finns det ojämna konturer separerade av tecknen "Z M" i d sträng, så vi delar upp d sträng längs den avgränsaren och iterera genom de där.
länder = len (root [4] [0]) - 1 för x i intervallet (länder): för sökväg i rot [4] [0] [x + 1]: för k i re.split ('Z M' path.attrib [ 'd']):
Vi delar sedan varje kontur av avgränsarna 'Z', 'L' eller 'M' för att få koordinaten för varje punkt i banan:
för x i intervallet (länder): för sökvägen i rot [4] [0] [x + 1]: för k i re.split ('Z M', path.attrib ['d']) .split ('Z | M | L', k):
Då tar vi bort alla icke-numeriska tecken från koordinaterna och delar dem i halva längs kommatecken, vilket ger breddgrader och längder. Om båda finns, lagrar vi dem i en sphereCoordinates
ordboken (i kartan, latitudkoordinaterna går från 0 till 180 °, men vi vill att de ska gå från -90 ° till 90 ° -nord och syd-så vi subtraherar 90 °).
för x i intervallet (länder): för sökvägen i rot [4] [0] [x + 1]: för k i re.split ('Z M', path.attrib ['d']) .split ('Z | M | L', k): breakup = re.split (',' re.sub ("[^ - 0123456789.,]", "", i)) vid uppbrytning [0] och brytning [1]: sphereCoordinates = sphereCoordinates ['long'] = float (uppbrytning [0]) sphereCoordinates ['lat'] = float (breakup [1]) - 90
Därefter om vi testa det genom att plotta några punkter (svgCircle (sfärPlot (sfärKoordinater, radie), 1, '# 333')
), vi får något så här:
Detta skiljer inte mellan punkter på den närbelägna sidan av världen och pekar på jordens andra sida. Om vi bara vill skriva ut prickar på den synliga sidan av planeten måste vi kunna ta reda på vilken sida av planeten en viss punkt är på.
Vi kan göra detta genom att beräkna de två punkterna på sfären där en stråle från kameran till punkten skulle korsa sfären. Denna funktion implementerar formeln för att lösa avstånden till de två punkterna-dNear och DFAR:
cameraDistanceSquare = sumOfSquares (kamera) #distans från klotts centrum till kamera def distanceToPoint (spherePoint): punkt = sphereToRect (rad, spherePoint ['long'], spherePoint ['lat']) ray = findVector (kamera, punkt) returvektorProjekt ray, cameraForward)
def occlude (spherePoint): punkt = sphereToRect (rad, spherePoint ['long'], spherePoint ['lat']) ray = findVector (kamera, punkt) d1 = magnitude (ray) #distans från kamera till punkt dot_l = np. dot (ray) ['x'] / d1, ray ['y'] / d1, ray ['z'] / d1], vectorToList (kamera)) #dot produkt av enhetsvektor från kamera till punkt och kameravektordeterminant = math.sqrt (abs ((dot_l) ** 2 - cameraDistanceSquare + radius ** 2)) dNear = - (dot_l) + determinant dFar = - (dot_l) - determinant
Om det faktiska avståndet till punkten, d1, är mindre än eller lika med både av dessa avstånd är punkten på sfärens närsida. På grund av avrundningsfel är ett litet wiggle-rum inbyggt i denna operation:
om d1 - 0.0000000001 <= dNear and d1 - 0.0000000001 <= dFar : return True else: return False
Om du använder den här funktionen som ett villkor bör du begränsa rendering till närliggande punkter:
om occlude (sphereCoordinates): svgCircle (sfärPlot (sfärKoordinater, radie), 1, '# 333')
Naturligtvis är prickarna inte sanna stängda, fyllda former - de ger bara illusionen av stängda former. Ritning av faktiska fyllda länder kräver lite mer sofistikering. Först och främst måste vi skriva ut hela alla synliga länder.
Det kan vi göra genom att skapa en strömbrytare som aktiveras när ett land innehåller en synlig punkt, samtidigt som den tillfälligt lagrar koordinaterna för det landet. Om omkopplaren är aktiverad, dras landet med de lagrade koordinaterna. Vi kommer också att rita polygoner istället för poäng.
för x i intervallet (länder): för sökväg i rot [4] [0] [x + 1]: för k i re.split ('Z M', path.attrib ['d']): countryIsVisible = Fel land = [] för jag i re.split ('Z | M | L', k): breakup = re.split (',' re.sub ("[^ - 0123456789.,]", "" ) om brytning [0] och brytning [1]: sphereCoordinates = sphereCoordinates ['long'] = float (uppbrytning [0]) sphereCoordinates ['lat'] = float (breakup [1]) - 90 #DRAW COUNTRY if occlude (sphereCoordinates): country.append ([sphereCoordinates, radius]) countryIsVisible = Äkta annars: country.append ([sphereCoordinates, radius]) om countryIsVisible: f.write ('\ N \ n ')
Det är svårt att säga, men länderna på kanten av världen klämmer in på sig själva, vilket vi inte vill ha (ta en titt på Brasilien).
För att göra länderna korrekt på världens kanter måste vi först spåra jordens skiva med en polygon (den skiva du ser från prickarna är en optisk illusion). Skivan är skisserad av världens synliga kant - en cirkel. Följande operationer beräknar radien och mitten av denna cirkel, liksom avståndet på planet som innehåller cirkeln från kameran och mittpunkten av världen.
#TRACE LIMB limbRadius = math.sqrt (radie ** 2 - radie ** 4 / cameraDistanceSquare) cx = kamera ['x'] * radie ** 2 / cameraDistanceSquare cy = kamera ['y'] * radie ** 2 / cameraDistanceSquare cz = kamera ['z'] * radie ** 2 / cameraDistanceSquare planeDistance = magnitude (kamera) * (1 - radie ** 2 / cameraDistanceSquare) planDisplacement = math.sqrt (cx ** 2 + cy ** 2 + cz ** 2)
Jorden och kameran (mörkgrå punkt) sedd ovanifrån. Den rosa linjen representerar jordens synliga kant. Endast den skuggade sektorn är synlig för kameran.
Då vi planerar en cirkel i det planet konstruerar vi två axlar parallellt med det planet:
#trade & negate x och y för att få en vinkelrätt vektor unitVectorCamera = unitVector (kamera) aV = unitVector ('x': -unitVectorCamera ['y'], 'y': unitVectorCamera ['x'], 'z': 0) bV = np.cross (vectorToList (aV), vectorToList (unitVectorCamera))
Därefter graverar vi bara på de axlarna med steg om 2 grader för att plotta en cirkel i det planet med den radie och mittpunkten (se denna förklaring till matematiken):
för t i intervallet (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])
Då inkapslar vi bara allt detta med polygonteckningskod:
f.write ('')
Vi skapar också en kopia av det objektet som ska användas senare som klippmask för alla våra länder:
f.write ('')
Det borde ge dig detta:
Med den nyberäknade disken kan vi ändra vår annan
uttalande i landets plotting code (för när koordinater ligger på dolda sidan av världen) för att plotta dessa punkter någonstans utanför skivan:
annars: tangentscale = (radien + planetDisplacement) / (pi * 0.5) rr = 1 + abs (math.tan ((distanceToPoint (sphereCoordinates) - planeDistance) / tangentscale)) country.append ([sphereCoordinates, radius * rr])
Detta använder en tangentkurva för att lyfta de dolda punkterna ovanför jordens yta, vilket ger utseendet att de sprids ut runt det:
Det här är inte helt matematiskt ljud (det bryter ner om kameran inte är grovt pekad i mitten av planeten), men det är enkelt och fungerar mest av tiden. Sedan genom att helt enkelt lägga till clip-path = "url (#clipglobe)"
till polygonteckningskoden kan vi snygga klippa länderna till kanten av världen:
om countryIsVisible: f.write ('Jag hoppas att du haft denna handledning! Ha det roligt med dina vektorglober!