diff --git a/src/draw.cpp b/src/draw.cpp
index 8fb4b3b..e7e007c 100644
--- a/src/draw.cpp
+++ b/src/draw.cpp
@@ -882,13 +882,13 @@ void GraphicsWindow::Paint() {
     }
 
     // If we've had a screenshot requested, take it now, before the UI is overlaid.
-    if(!SS.screenshotFile.empty()) {
-        FILE *f = ssfopen(SS.screenshotFile, "wb");
+    if(!SS.screenshotFile.IsEmpty()) {
+        FILE *f = OpenFile(SS.screenshotFile, "wb");
         if(!f || !canvas->ReadFrame()->WritePng(f, /*flip=*/true)) {
-            Error("Couldn't write to '%s'", SS.screenshotFile.c_str());
+            Error("Couldn't write to '%s'", SS.screenshotFile.raw.c_str());
         }
         if(f) fclose(f);
-        SS.screenshotFile.clear();
+        SS.screenshotFile.Clear();
     }
 
     // And finally the toolbar.
diff --git a/src/export.cpp b/src/export.cpp
index c10567d..bafd685 100644
--- a/src/export.cpp
+++ b/src/export.cpp
@@ -8,7 +8,7 @@
 //-----------------------------------------------------------------------------
 #include "solvespace.h"
 
-void SolveSpaceUI::ExportSectionTo(const std::string &filename) {
+void SolveSpaceUI::ExportSectionTo(const Platform::Path &filename) {
     Vector gn = (SS.GW.projRight).Cross(SS.GW.projUp);
     gn = gn.WithMagnitude(1);
 
@@ -165,7 +165,7 @@ public:
     }
 };
 
-void SolveSpaceUI::ExportViewOrWireframeTo(const std::string &filename, bool exportWireframe) {
+void SolveSpaceUI::ExportViewOrWireframeTo(const Platform::Path &filename, bool exportWireframe) {
     int i;
     SEdgeList edges = {};
     SBezierList beziers = {};
@@ -605,29 +605,29 @@ double VectorFileWriter::MmToPts(double mm) {
     return (mm/25.4)*72;
 }
 
-VectorFileWriter *VectorFileWriter::ForFile(const std::string &filename) {
+VectorFileWriter *VectorFileWriter::ForFile(const Platform::Path &filename) {
     VectorFileWriter *ret;
     bool needOpen = true;
-    if(FilenameHasExtension(filename, ".dxf")) {
+    if(filename.HasExtension("dxf")) {
         static DxfFileWriter DxfWriter;
         ret = &DxfWriter;
         needOpen = false;
-    } else if(FilenameHasExtension(filename, ".ps") || FilenameHasExtension(filename, ".eps")) {
+    } else if(filename.HasExtension("ps") || filename.HasExtension("eps")) {
         static EpsFileWriter EpsWriter;
         ret = &EpsWriter;
-    } else if(FilenameHasExtension(filename, ".pdf")) {
+    } else if(filename.HasExtension("pdf")) {
         static PdfFileWriter PdfWriter;
         ret = &PdfWriter;
-    } else if(FilenameHasExtension(filename, ".svg")) {
+    } else if(filename.HasExtension("svg")) {
         static SvgFileWriter SvgWriter;
         ret = &SvgWriter;
-    } else if(FilenameHasExtension(filename, ".plt")||FilenameHasExtension(filename, ".hpgl")) {
+    } else if(filename.HasExtension("plt") || filename.HasExtension("hpgl")) {
         static HpglFileWriter HpglWriter;
         ret = &HpglWriter;
-    } else if(FilenameHasExtension(filename, ".step")||FilenameHasExtension(filename, ".stp")) {
+    } else if(filename.HasExtension("step") || filename.HasExtension("stp")) {
         static Step2dFileWriter Step2dWriter;
         ret = &Step2dWriter;
-    } else if(FilenameHasExtension(filename, ".txt")||FilenameHasExtension(filename, ".ngc")) {
+    } else if(filename.HasExtension("txt") || filename.HasExtension("ngc")) {
         static GCodeFileWriter GCodeWriter;
         ret = &GCodeWriter;
     } else {
@@ -635,15 +635,15 @@ VectorFileWriter *VectorFileWriter::ForFile(const std::string &filename) {
         "filename '%s'; try "
         ".step, .stp, .dxf, .svg, .plt, .hpgl, .pdf, .txt, .ngc, "
         ".eps, or .ps.",
-            filename.c_str());
+            filename.raw.c_str());
         return NULL;
     }
     ret->filename = filename;
     if(!needOpen) return ret;
 
-    FILE *f = ssfopen(filename, "wb");
+    FILE *f = OpenFile(filename, "wb");
     if(!f) {
-        Error("Couldn't write to '%s'", filename.c_str());
+        Error("Couldn't write to '%s'", filename.raw.c_str());
         return NULL;
     }
     ret->f = f;
@@ -793,7 +793,7 @@ void VectorFileWriter::BezierAsNonrationalCubic(SBezier *sb, int depth) {
 //-----------------------------------------------------------------------------
 // Export a triangle mesh, in the requested format.
 //-----------------------------------------------------------------------------
-void SolveSpaceUI::ExportMeshTo(const std::string &filename) {
+void SolveSpaceUI::ExportMeshTo(const Platform::Path &filename) {
     SS.exportMode = true;
     GenerateAll(Generate::ALL);
 
@@ -806,33 +806,33 @@ void SolveSpaceUI::ExportMeshTo(const std::string &filename) {
         return;
     }
 
-    FILE *f = ssfopen(filename, "wb");
+    FILE *f = OpenFile(filename, "wb");
     if(!f) {
-        Error("Couldn't write to '%s'", filename.c_str());
+        Error("Couldn't write to '%s'", filename.raw.c_str());
         return;
     }
     ShowNakedEdges(/*reportOnlyWhenNotOkay=*/true);
-    if(FilenameHasExtension(filename, ".stl")) {
+    if(filename.HasExtension("stl")) {
         ExportMeshAsStlTo(f, m);
-    } else if(FilenameHasExtension(filename, ".obj")) {
-        std::string mtlFilename = filename.substr(0, filename.length() - 4) + ".mtl";
-        FILE *fMtl = ssfopen(mtlFilename, "wb");
+    } else if(filename.HasExtension("obj")) {
+        Platform::Path mtlFilename = filename.WithExtension("mtl");
+        FILE *fMtl = OpenFile(mtlFilename, "wb");
         if(!fMtl) {
-            Error("Couldn't write to '%s'", filename.c_str());
+            Error("Couldn't write to '%s'", filename.raw.c_str());
             return;
         }
 
-        fprintf(f, "mtllib %s\n", Basename(mtlFilename).c_str());
+        fprintf(f, "mtllib %s\n", mtlFilename.FileName().c_str());
         ExportMeshAsObjTo(f, fMtl, m);
 
         fclose(fMtl);
-    } else if(FilenameHasExtension(filename, ".js") ||
-              FilenameHasExtension(filename, ".html")) {
+    } else if(filename.HasExtension("js") ||
+              filename.HasExtension("html")) {
         SOutlineList *e = &(SK.GetGroup(SS.GW.activeGroup)->displayOutlines);
         ExportMeshAsThreeJsTo(f, filename, m, e);
     } else {
         Error("Can't identify output file type from file extension of "
-              "filename '%s'; try .stl, .obj, .js, .html.", filename.c_str());
+              "filename '%s'; try .stl, .obj, .js, .html.", filename.raw.c_str());
     }
 
     fclose(f);
@@ -931,7 +931,7 @@ void SolveSpaceUI::ExportMeshAsObjTo(FILE *fObj, FILE *fMtl, SMesh *sm) {
 //-----------------------------------------------------------------------------
 // Export the mesh as a JavaScript script, which is compatible with Three.js.
 //-----------------------------------------------------------------------------
-void SolveSpaceUI::ExportMeshAsThreeJsTo(FILE *f, const std::string &filename,
+void SolveSpaceUI::ExportMeshAsThreeJsTo(FILE *f, const Platform::Path &filename,
                                          SMesh *sm, SOutlineList *sol)
 {
     SPointList spl = {};
@@ -986,14 +986,14 @@ void SolveSpaceUI::ExportMeshAsThreeJsTo(FILE *f, const std::string &filename,
     double largerBoundXY = max((bndh.x - bndl.x), (bndh.y - bndl.y));
     double largerBoundZ = max(largerBoundXY, (bndh.z - bndl.z + 1));
 
-    std::string basename = Basename(filename, /*stripExtension=*/true);
+    std::string basename = filename.FileStem();
     for(size_t i = 0; i < basename.length(); i++) {
         if(!(isalnum(basename[i]) || ((unsigned)basename[i] >= 0x80))) {
             basename[i] = '_';
         }
     }
 
-    if(FilenameHasExtension(filename, "html")) {
+    if(filename.HasExtension("html")) {
         fprintf(f, htmlbegin,
                 LoadStringFromGzip("threejs/three-r76.js.gz").c_str(),
                 LoadStringFromGzip("threejs/hammer-2.0.8.js.gz").c_str(),
@@ -1089,7 +1089,7 @@ void SolveSpaceUI::ExportMeshAsThreeJsTo(FILE *f, const std::string &filename,
 
     fputs("  ]\n};\n", f);
 
-    if(FilenameHasExtension(filename, "html")) {
+    if(filename.HasExtension("html")) {
         fprintf(f, htmlend,
                 basename.c_str(),
                 SS.GW.scale,
@@ -1105,7 +1105,7 @@ void SolveSpaceUI::ExportMeshAsThreeJsTo(FILE *f, const std::string &filename,
 // Export a view of the model as an image; we just take a screenshot, by
 // rendering the view in the usual way and then copying the pixels.
 //-----------------------------------------------------------------------------
-void SolveSpaceUI::ExportAsPngTo(const std::string &filename) {
+void SolveSpaceUI::ExportAsPngTo(const Platform::Path &filename) {
     screenshotFile = filename;
     // The rest of the work is done in the next redraw.
     InvalidateGraphics();
diff --git a/src/exportstep.cpp b/src/exportstep.cpp
index af4270e..970c925 100644
--- a/src/exportstep.cpp
+++ b/src/exportstep.cpp
@@ -292,7 +292,7 @@ void StepFileWriter::WriteFooter() {
         );
 }
 
-void StepFileWriter::ExportSurfacesTo(const std::string &filename) {
+void StepFileWriter::ExportSurfacesTo(const Platform::Path &filename) {
     Group *g = SK.GetGroup(SS.GW.activeGroup);
     SShell *shell = &(g->runningShell);
 
@@ -305,9 +305,9 @@ void StepFileWriter::ExportSurfacesTo(const std::string &filename) {
         return;
     }
 
-    f = ssfopen(filename, "wb");
+    f = OpenFile(filename, "wb");
     if(!f) {
-        Error("Couldn't write to '%s'", filename.c_str());
+        Error("Couldn't write to '%s'", filename.raw.c_str());
         return;
     }
 
diff --git a/src/exportvector.cpp b/src/exportvector.cpp
index 5ab335d..8d637d2 100644
--- a/src/exportvector.cpp
+++ b/src/exportvector.cpp
@@ -582,7 +582,7 @@ void DxfFileWriter::FinishAndCloseFile() {
     constraint = NULL;
 
     if(!WriteFile(filename, stream.str())) {
-        Error("Couldn't write to '%s'", filename.c_str());
+        Error("Couldn't write to '%s'", filename.raw.c_str());
         return;
     }
 
diff --git a/src/file.cpp b/src/file.cpp
index 7758826..99ac2c7 100644
--- a/src/file.cpp
+++ b/src/file.cpp
@@ -116,8 +116,8 @@ const SolveSpaceUI::SaveTable SolveSpaceUI::SAVED[] = {
     { 'g',  "Group.allDimsReference",   'b',    &(SS.sv.g.allDimsReference)   },
     { 'g',  "Group.scale",              'f',    &(SS.sv.g.scale)              },
     { 'g',  "Group.remap",              'M',    &(SS.sv.g.remap)              },
-    { 'g',  "Group.impFile",            'S',    &(SS.sv.g.linkFile)           },
-    { 'g',  "Group.impFileRel",         'S',    &(SS.sv.g.linkFileRel)        },
+    { 'g',  "Group.impFile",            'i',    NULL                          },
+    { 'g',  "Group.impFileRel",         'P',    &(SS.sv.g.linkFile)           },
 
     { 'p',  "Param.h.v.",               'x',    &(SS.sv.p.h.v)                },
     { 'p',  "Param.val",                'f',    &(SS.sv.p.val)                },
@@ -209,6 +209,7 @@ const SolveSpaceUI::SaveTable SolveSpaceUI::SAVED[] = {
 struct SAVEDptr {
     IdList<EntityMap,EntityId> &M() { return *((IdList<EntityMap,EntityId> *)this); }
     std::string                &S() { return *((std::string *)this); }
+    Platform::Path             &P() { return *((Platform::Path *)this); }
     bool      &b() { return *((bool *)this); }
     RgbaColor &c() { return *((RgbaColor *)this); }
     int       &d() { return *((int *)this); }
@@ -216,7 +217,7 @@ struct SAVEDptr {
     uint32_t  &x() { return *((uint32_t *)this); }
 };
 
-void SolveSpaceUI::SaveUsingTable(int type) {
+void SolveSpaceUI::SaveUsingTable(const Platform::Path &filename, int type) {
     int i;
     for(i = 0; SAVED[i].type != 0; i++) {
         if(SAVED[i].type != type) continue;
@@ -225,9 +226,11 @@ void SolveSpaceUI::SaveUsingTable(int type) {
         SAVEDptr *p = (SAVEDptr *)SAVED[i].ptr;
         // Any items that aren't specified are assumed to be zero
         if(fmt == 'S' && p->S().empty())          continue;
+        if(fmt == 'P' && p->P().IsEmpty())        continue;
         if(fmt == 'd' && p->d() == 0)             continue;
         if(fmt == 'f' && EXACT(p->f() == 0.0))    continue;
         if(fmt == 'x' && p->x() == 0)             continue;
+        if(fmt == 'i')                            continue;
 
         fprintf(fh, "%s=", SAVED[i].desc);
         switch(fmt) {
@@ -238,6 +241,15 @@ void SolveSpaceUI::SaveUsingTable(int type) {
             case 'f': fprintf(fh, "%.20f", p->f());               break;
             case 'x': fprintf(fh, "%08x",  p->x());               break;
 
+            case 'P': {
+                if(!p->P().IsEmpty()) {
+                    Platform::Path relativePath = p->P().RelativeTo(filename.Parent());
+                    ssassert(!relativePath.IsEmpty(), "Cannot relativize path");
+                    fprintf(fh, "%s", relativePath.ToPortable().c_str());
+                }
+                break;
+            }
+
             case 'M': {
                 int j;
                 fprintf(fh, "{\n");
@@ -250,23 +262,32 @@ void SolveSpaceUI::SaveUsingTable(int type) {
                 break;
             }
 
+            case 'i': break;
+
             default: ssassert(false, "Unexpected value format");
         }
         fprintf(fh, "\n");
     }
 }
 
-bool SolveSpaceUI::SaveToFile(const std::string &filename) {
-    // Make sure all the entities are regenerated up to date, since they
-    // will be exported. We reload the linked files because that rewrites
-    // the linkFileRel for our possibly-new filename.
+bool SolveSpaceUI::SaveToFile(const Platform::Path &filename) {
+    // Make sure all the entities are regenerated up to date, since they will be exported.
     SS.ScheduleShowTW();
-    SS.ReloadAllImported(filename);
     SS.GenerateAll(SolveSpaceUI::Generate::ALL);
 
-    fh = ssfopen(filename, "wb");
+    for(Group &g : SK.group) {
+        if(g.type != Group::Type::LINKED) continue;
+
+        if(g.linkFile.RelativeTo(filename).IsEmpty()) {
+            Error("This sketch links the sketch '%s'; it can only be saved "
+                  "on the same volume.", g.linkFile.raw.c_str());
+            return false;
+        }
+    }
+
+    fh = OpenFile(filename, "wb");
     if(!fh) {
-        Error("Couldn't write to file '%s'", filename.c_str());
+        Error("Couldn't write to file '%s'", filename.raw.c_str());
         return false;
     }
 
@@ -275,39 +296,39 @@ bool SolveSpaceUI::SaveToFile(const std::string &filename) {
     int i, j;
     for(i = 0; i < SK.group.n; i++) {
         sv.g = SK.group.elem[i];
-        SaveUsingTable('g');
+        SaveUsingTable(filename, 'g');
         fprintf(fh, "AddGroup\n\n");
     }
 
     for(i = 0; i < SK.param.n; i++) {
         sv.p = SK.param.elem[i];
-        SaveUsingTable('p');
+        SaveUsingTable(filename, 'p');
         fprintf(fh, "AddParam\n\n");
     }
 
     for(i = 0; i < SK.request.n; i++) {
         sv.r = SK.request.elem[i];
-        SaveUsingTable('r');
+        SaveUsingTable(filename, 'r');
         fprintf(fh, "AddRequest\n\n");
     }
 
     for(i = 0; i < SK.entity.n; i++) {
         (SK.entity.elem[i]).CalculateNumerical(/*forExport=*/true);
         sv.e = SK.entity.elem[i];
-        SaveUsingTable('e');
+        SaveUsingTable(filename, 'e');
         fprintf(fh, "AddEntity\n\n");
     }
 
     for(i = 0; i < SK.constraint.n; i++) {
         sv.c = SK.constraint.elem[i];
-        SaveUsingTable('c');
+        SaveUsingTable(filename, 'c');
         fprintf(fh, "AddConstraint\n\n");
     }
 
     for(i = 0; i < SK.style.n; i++) {
         sv.s = SK.style.elem[i];
         if(sv.s.h.v >= Style::FIRST_CUSTOM) {
-            SaveUsingTable('s');
+            SaveUsingTable(filename, 's');
             fprintf(fh, "AddStyle\n\n");
         }
     }
@@ -373,7 +394,7 @@ bool SolveSpaceUI::SaveToFile(const std::string &filename) {
     return true;
 }
 
-void SolveSpaceUI::LoadUsingTable(char *key, char *val) {
+void SolveSpaceUI::LoadUsingTable(const Platform::Path &filename, char *key, char *val) {
     int i;
     for(i = 0; SAVED[i].type != 0; i++) {
         if(strcmp(SAVED[i].desc, key)==0) {
@@ -386,15 +407,16 @@ void SolveSpaceUI::LoadUsingTable(char *key, char *val) {
                 case 'f': p->f() = atof(val);               break;
                 case 'x': sscanf(val, "%x", &u); p->x()= u; break;
 
+                case 'P': {
+                    p->P() = filename.Parent().Join(Platform::Path::FromPortable(val));
+                    break;
+                }
+
                 case 'c':
                     sscanf(val, "%x", &u);
                     p->c() = RgbaColor::FromPackedInt(u);
                     break;
 
-                case 'P':
-                    p->S() = val;
-                    break;
-
                 case 'M': {
                     // Don't clear this list! When the group gets added, it
                     // makes a shallow copy, so that would result in us
@@ -417,6 +439,8 @@ void SolveSpaceUI::LoadUsingTable(char *key, char *val) {
                     break;
                 }
 
+                case 'i': break;
+
                 default: ssassert(false, "Unexpected value format");
             }
             break;
@@ -427,13 +451,13 @@ void SolveSpaceUI::LoadUsingTable(char *key, char *val) {
     }
 }
 
-bool SolveSpaceUI::LoadFromFile(const std::string &filename, bool canCancel) {
+bool SolveSpaceUI::LoadFromFile(const Platform::Path &filename, bool canCancel) {
     allConsistent = false;
     fileLoadError = false;
 
-    fh = ssfopen(filename, "rb");
+    fh = OpenFile(filename, "rb");
     if(!fh) {
-        Error("Couldn't read from file '%s'", filename.c_str());
+        Error("Couldn't read from file '%s'", filename.raw.c_str());
         return false;
     }
 
@@ -458,7 +482,7 @@ bool SolveSpaceUI::LoadFromFile(const std::string &filename, bool canCancel) {
         if(e) {
             *e = '\0';
             char *key = line, *val = e+1;
-            LoadUsingTable(key, val);
+            LoadUsingTable(filename, key, val);
         } else if(strcmp(line, "AddGroup")==0) {
             // legacy files have a spurious dependency between linked groups
             // and their parent groups, remove
@@ -660,13 +684,13 @@ void SolveSpaceUI::UpgradeLegacyData() {
     oldParam.Clear();
 }
 
-bool SolveSpaceUI::LoadEntitiesFromFile(const std::string &filename, EntityList *le,
+bool SolveSpaceUI::LoadEntitiesFromFile(const Platform::Path &filename, EntityList *le,
                                         SMesh *m, SShell *sh)
 {
     SSurface srf = {};
     SCurve crv = {};
 
-    fh = ssfopen(filename, "rb");
+    fh = OpenFile(filename, "rb");
     if(!fh) return false;
 
     le->Clear();
@@ -687,7 +711,7 @@ bool SolveSpaceUI::LoadEntitiesFromFile(const std::string &filename, EntityList
         if(e) {
             *e = '\0';
             char *key = line, *val = e+1;
-            LoadUsingTable(key, val);
+            LoadUsingTable(filename, key, val);
         } else if(strcmp(line, "AddGroup")==0) {
             // Don't leak memory; these get allocated whether we want them
             // or not.
@@ -795,173 +819,51 @@ bool SolveSpaceUI::LoadEntitiesFromFile(const std::string &filename, EntityList
     return true;
 }
 
-//-----------------------------------------------------------------------------
-// Handling of the relative-absolute path transformations for links
-//-----------------------------------------------------------------------------
-static std::vector<std::string> Split(const std::string &haystack, const std::string &needle)
+bool SolveSpaceUI::ReloadAllImported(const Platform::Path &filename, bool canCancel)
 {
-    std::vector<std::string> result;
+    std::map<Platform::Path, Platform::Path, Platform::PathLess> linkMap;
 
-    size_t oldpos = 0, pos = 0;
-    while(true) {
-        oldpos = pos;
-        pos = haystack.find(needle, pos);
-        if(pos == std::string::npos) break;
-        result.push_back(haystack.substr(oldpos, pos - oldpos));
-        pos += needle.length();
-    }
-
-    if(oldpos != haystack.length() - 1)
-        result.push_back(haystack.substr(oldpos));
-
-    return result;
-}
-
-static std::string Join(const std::vector<std::string> &parts, const std::string &separator)
-{
-    bool first = true;
-    std::string result;
-    for(auto &part : parts) {
-        if(!first) result += separator;
-        result += part;
-        first = false;
-    }
-    return result;
-}
-
-static std::string MakePathRelative(const std::string &base, const std::string &path)
-{
-    std::vector<std::string> baseParts = Split(base, PATH_SEP),
-                             pathParts = Split(path, PATH_SEP),
-                             resultParts;
-    baseParts.pop_back();
-
-    size_t common;
-    for(common = 0; common < baseParts.size() && common < pathParts.size(); common++) {
-        if(!PathEqual(baseParts[common], pathParts[common]))
-            break;
-    }
-
-    for(size_t i = common; i < baseParts.size(); i++)
-        resultParts.push_back("..");
-
-    resultParts.insert(resultParts.end(),
-                       pathParts.begin() + common, pathParts.end());
-
-    return Join(resultParts, PATH_SEP);
-}
-
-static std::string MakePathAbsolute(const std::string &base, const std::string &path)
-{
-    std::vector<std::string> resultParts = Split(base, PATH_SEP),
-                             pathParts = Split(path, PATH_SEP);
-    resultParts.pop_back();
-
-    for(auto &part : pathParts) {
-        if(part == ".") {
-            /* do nothing */
-        } else if(part == "..") {
-            ssassert(!resultParts.empty(), "Relative path pointing outside of root directory");
-            resultParts.pop_back();
-        } else {
-            resultParts.push_back(part);
-        }
-    }
-
-    return Join(resultParts, PATH_SEP);
-}
-
-static void PathSepNormalize(std::string &filename)
-{
-    for(size_t i = 0; i < filename.length(); i++) {
-        if(filename[i] == '\\')
-            filename[i] = '/';
-    }
-}
-
-bool SolveSpaceUI::ReloadAllImported(const std::string &filename, bool canCancel)
-{
-    std::string saveFile = filename.empty() ? SS.saveFile : filename;
-    std::map<std::string, std::string> linkMap;
     allConsistent = false;
+    for(Group &g : SK.group) {
+        if(g.type != Group::Type::LINKED) continue;
 
-    int i;
-    for(i = 0; i < SK.group.n; i++) {
-        Group *g = &(SK.group.elem[i]);
-        if(g->type != Group::Type::LINKED) continue;
+        g.impEntity.Clear();
+        g.impMesh.Clear();
+        g.impShell.Clear();
 
-        if(isalpha(g->linkFile[0]) && g->linkFile[1] == ':') {
-            // Make sure that g->linkFileRel always contains a relative path
-            // in an UNIX format, even after we load an old file which had
-            // the path in Windows format
-            PathSepNormalize(g->linkFileRel);
+        // If we prompted for this specific file before, don't ask again.
+        if(linkMap.count(g.linkFile)) {
+            g.linkFile = linkMap[g.linkFile];
         }
 
-        g->impEntity.Clear();
-        g->impMesh.Clear();
-        g->impShell.Clear();
-
-        if(linkMap.count(g->linkFile)) {
-            std::string newPath = linkMap[g->linkFile];
-            if(!newPath.empty())
-                g->linkFile = newPath;
-        }
-
-        // In a newly created group we only have an absolute path.
-        if(!g->linkFileRel.empty()) {
-            std::string rel = PathSepUnixToPlatform(g->linkFileRel);
-            std::string fromRel = MakePathAbsolute(saveFile, rel);
-            FILE *test = ssfopen(fromRel, "rb");
-            if(test) {
-                fclose(test);
-                // Okay, exists; update the absolute path.
-                g->linkFile = fromRel;
-            } else {
-                // It doesn't exist. Perhaps the file was moved but the tree wasn't, and we
-                // can use the absolute filename to get us back. The relative path will be
-                // updated below.
-            }
-        }
-
-try_load_file:
-        if(LoadEntitiesFromFile(g->linkFile, &(g->impEntity), &(g->impMesh), &(g->impShell)))
-        {
-            if(!saveFile.empty()) {
-                // Record the linked file's name relative to our filename;
-                // if the entire tree moves, then everything will still work
-                std::string rel = MakePathRelative(saveFile, g->linkFile);
-                g->linkFileRel = PathSepPlatformToUnix(rel);
-            } else {
-                // We're not yet saved, so can't make it absolute.
-                // This will only be used for display purposes, as saveFile
-                // is always nonempty when we are actually writing anything.
-                g->linkFileRel = g->linkFile;
-            }
-        } else if(!linkMap.count(g->linkFile)) {
-            switch(LocateImportedFileYesNoCancel(g->linkFileRel, canCancel)) {
+try_again:
+        if(LoadEntitiesFromFile(g.linkFile, &g.impEntity, &g.impMesh, &g.impShell)) {
+            // We loaded the data, good.
+        } else if(linkMap.count(g.linkFile) == 0) {
+            // The file was moved; prompt the user for its new location.
+            switch(LocateImportedFileYesNoCancel(g.linkFile.RelativeTo(filename), canCancel)) {
             case DIALOG_YES: {
-                std::string oldImpFile = g->linkFile;
-                if(!GetOpenFile(&g->linkFile, "", SlvsFileFilter)) {
-                    if(canCancel)
-                        return false;
-                    break;
+                Platform::Path newLinkFile;
+                if(GetOpenFile(&newLinkFile, "", SlvsFileFilter)) {
+                    linkMap[g.linkFile] = newLinkFile;
+                    g.linkFile = newLinkFile;
+                    goto try_again;
                 } else {
-                    linkMap[oldImpFile] = g->linkFile;
-                    goto try_load_file;
+                    if(canCancel) return false;
+                    break;
                 }
             }
 
             case DIALOG_NO:
-                linkMap[g->linkFile] = "";
-                /* Geometry will be pruned by GenerateAll(). */
+                linkMap[g.linkFile].Clear();
+                // Geometry will be pruned by GenerateAll().
                 break;
 
             case DIALOG_CANCEL:
                 return false;
             }
         } else {
-            // User was already asked to and refused to locate a missing
-            // linked file.
+            // User was already asked to and refused to locate a missing linked file.
         }
     }
 
diff --git a/src/graphicswin.cpp b/src/graphicswin.cpp
index 1c44580..e634e9e 100644
--- a/src/graphicswin.cpp
+++ b/src/graphicswin.cpp
@@ -943,7 +943,7 @@ void GraphicsWindow::MenuEdit(Command id) {
             break;
 
         case Command::REGEN_ALL:
-            SS.ReloadAllImported();
+            SS.ReloadAllImported(SS.saveFile);
             SS.GenerateAll(SolveSpaceUI::Generate::UNTIL_ACTIVE);
             SS.ScheduleShowTW();
             break;
diff --git a/src/group.cpp b/src/group.cpp
index 041635b..4671c86 100644
--- a/src/group.cpp
+++ b/src/group.cpp
@@ -229,13 +229,13 @@ void Group::MenuGroup(Command id) {
         case Command::GROUP_LINK: {
             g.type = Type::LINKED;
             g.meshCombine = CombineAs::ASSEMBLE;
-            if(g.linkFile.empty()) {
+            if(g.linkFile.IsEmpty()) {
                 if(!GetOpenFile(&g.linkFile, "", SlvsFileFilter)) return;
             }
 
             // Assign the default name of the group based on the name of
             // the linked file.
-            g.name = Basename(g.linkFile, /*stripExtension=*/true);
+            g.name = g.linkFile.FileStem();
             for(size_t i = 0; i < g.name.length(); i++) {
                 if(!(isalnum(g.name[i]) || (unsigned)g.name[i] >= 0x80)) {
                     // convert punctuation to dashes
@@ -274,7 +274,7 @@ void Group::MenuGroup(Command id) {
     Group *gg = SK.GetGroup(g.h);
 
     if(gg->type == Type::LINKED) {
-        SS.ReloadAllImported();
+        SS.ReloadAllImported(SS.saveFile);
     }
     gg->clean = false;
     SS.GW.activeGroup = gg->h;
diff --git a/src/importdxf.cpp b/src/importdxf.cpp
index 38b0aab..7eb3616 100644
--- a/src/importdxf.cpp
+++ b/src/importdxf.cpp
@@ -1068,13 +1068,13 @@ public:
     }
 };
 
-static void ImportDwgDxf(const std::string &filename,
+static void ImportDwgDxf(const Platform::Path &filename,
                          std::function<bool(const std::string &data, DRW_Interface *intf)> read) {
-    std::string fileType = ToUpper(Extension(filename));
+    std::string fileType = ToUpper(filename.Extension());
 
     std::string data;
     if(!ReadFile(filename, &data)) {
-        Error("Couldn't read from '%s'", filename.c_str());
+        Error("Couldn't read from '%s'", filename.raw.c_str());
         return;
     }
 
@@ -1107,14 +1107,14 @@ static void ImportDwgDxf(const std::string &filename,
     }
 }
 
-void ImportDxf(const std::string &filename) {
+void ImportDxf(const Platform::Path &filename) {
     ImportDwgDxf(filename, [](const std::string &data, DRW_Interface *intf) {
         std::stringstream stream(data);
         return dxfRW().read(stream, intf, /*ext=*/false);
     });
 }
 
-void ImportDwg(const std::string &filename) {
+void ImportDwg(const Platform::Path &filename) {
     ImportDwgDxf(filename, [](const std::string &data, DRW_Interface *intf) {
         std::stringstream stream(data);
         return dwgR().read(stream, intf, /*ext=*/false);
diff --git a/src/platform/climain.cpp b/src/platform/climain.cpp
index 318494a..52881ac 100644
--- a/src/platform/climain.cpp
+++ b/src/platform/climain.cpp
@@ -93,13 +93,13 @@ static bool RunCommand(const std::vector<std::string> args) {
         }
     }
 
-    std::function<void(const std::string &)> runner;
+    std::function<void(const Platform::Path &)> runner;
 
-    std::vector<std::string> inputFiles;
+    std::vector<Platform::Path> inputFiles;
     auto ParseInputFile = [&](size_t &argn) {
         std::string arg = args[argn];
         if(arg[0] != '-') {
-            inputFiles.push_back(arg);
+            inputFiles.push_back(Platform::Path::From(arg));
             return true;
         } else return false;
     };
@@ -190,7 +190,7 @@ static bool RunCommand(const std::vector<std::string> args) {
             return false;
         }
 
-        runner = [&](const std::string &output) {
+        runner = [&](const Platform::Path &output) {
             SS.GW.width     = width;
             SS.GW.height    = height;
             SS.GW.projRight = projRight;
@@ -218,7 +218,7 @@ static bool RunCommand(const std::vector<std::string> args) {
             return false;
         }
 
-        runner = [&](const std::string &output) {
+        runner = [&](const Platform::Path &output) {
             SS.GW.projRight   = projRight;
             SS.GW.projUp      = projUp;
             SS.exportChordTol = chordTol;
@@ -235,7 +235,7 @@ static bool RunCommand(const std::vector<std::string> args) {
             }
         }
 
-        runner = [&](const std::string &output) {
+        runner = [&](const Platform::Path &output) {
             SS.exportChordTol = chordTol;
 
             SS.ExportViewOrWireframeTo(output, /*exportWireframe=*/true);
@@ -250,7 +250,7 @@ static bool RunCommand(const std::vector<std::string> args) {
             }
         }
 
-        runner = [&](const std::string &output) {
+        runner = [&](const Platform::Path &output) {
             SS.exportChordTol = chordTol;
 
             SS.ExportMeshTo(output);
@@ -264,7 +264,7 @@ static bool RunCommand(const std::vector<std::string> args) {
             }
         }
 
-        runner = [&](const std::string &output) {
+        runner = [&](const Platform::Path &output) {
             StepFileWriter sfw = {};
             sfw.ExportSurfacesTo(output);
         };
@@ -278,7 +278,7 @@ static bool RunCommand(const std::vector<std::string> args) {
 
         outputPattern = "%.slvs";
 
-        runner = [&](const std::string &output) {
+        runner = [&](const Platform::Path &output) {
             SS.SaveToFile(output);
         };
     } else {
@@ -300,25 +300,21 @@ static bool RunCommand(const std::vector<std::string> args) {
         return false;
     }
 
-    for(const std::string &inputFile : inputFiles) {
-        std::string absInputFile = PathFromCurrentDirectory(inputFile);
+    for(const Platform::Path &inputFile : inputFiles) {
+        Platform::Path absInputFile = inputFile.Expand(/*fromCurrentDirectory=*/true);
 
-        std::string outputFile = outputPattern;
-        size_t replaceAt = outputFile.find('%');
+        Platform::Path outputFile = Platform::Path::From(outputPattern);
+        size_t replaceAt = outputFile.raw.find('%');
         if(replaceAt != std::string::npos) {
-            std::string outputSubst;
-            outputSubst  = Dirname(inputFile);
-            if(!outputSubst.empty()) {
-                outputSubst += PATH_SEP;
-            }
-            outputSubst += Basename(inputFile, /*stripExtension=*/true);
-            outputFile.replace(replaceAt, 1, outputSubst);
+            Platform::Path outputSubst = inputFile.Parent();
+            outputSubst = outputSubst.Join(inputFile.FileStem());
+            outputFile.raw.replace(replaceAt, 1, outputSubst.raw);
         }
-        std::string absOutputFile = PathFromCurrentDirectory(outputFile);
+        Platform::Path absOutputFile = outputFile.Expand(/*fromCurrentDirectory=*/true);
 
         SS.Init();
         if(!SS.LoadFromFile(absInputFile)) {
-            fprintf(stderr, "Cannot load '%s'!\n", inputFile.c_str());
+            fprintf(stderr, "Cannot load '%s'!\n", inputFile.raw.c_str());
             return false;
         }
         SS.AfterNewFile();
@@ -326,7 +322,7 @@ static bool RunCommand(const std::vector<std::string> args) {
         SK.Clear();
         SS.Clear();
 
-        fprintf(stderr, "Written '%s'.\n", outputFile.c_str());
+        fprintf(stderr, "Written '%s'.\n", outputFile.raw.c_str());
     }
 
     return true;
diff --git a/src/platform/cocoamain.mm b/src/platform/cocoamain.mm
index e2676ff..c1c03fe 100644
--- a/src/platform/cocoamain.mm
+++ b/src/platform/cocoamain.mm
@@ -474,9 +474,9 @@ void PaintGraphics() {
     CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0, YES);
 }
 
-void SetCurrentFilename(const std::string &filename) {
-    if(!filename.empty()) {
-        [GW setTitleWithRepresentedFilename:Wrap(filename)];
+void SetCurrentFilename(const Platform::Path &filename) {
+    if(!filename.IsEmpty()) {
+        [GW setTitleWithRepresentedFilename:Wrap(filename.raw)];
     } else {
         [GW setTitle:Wrap(C_("title", "(new sketch)"))];
         [GW setRepresentedFilename:@""];
@@ -705,18 +705,17 @@ static void RefreshRecentMenu(SolveSpace::Command cmd, SolveSpace::Command base)
     NSMenu *menu = [[NSMenu alloc] initWithTitle:@""];
     [recent setSubmenu:menu];
 
-    if(std::string(RecentFile[0]).empty()) {
+    if(RecentFile[0].IsEmpty()) {
         NSMenuItem *placeholder = [[NSMenuItem alloc]
             initWithTitle:Wrap(_("(no recent files)")) action:nil keyEquivalent:@""];
         [placeholder setEnabled:NO];
         [menu addItem:placeholder];
     } else {
         for(size_t i = 0; i < MAX_RECENT; i++) {
-            if(std::string(RecentFile[i]).empty())
-                break;
+            if(RecentFile[i].IsEmpty()) break;
 
             NSMenuItem *item = [[NSMenuItem alloc]
-                initWithTitle:[Wrap(RecentFile[i])
+                initWithTitle:[Wrap(RecentFile[i].raw)
                     stringByAbbreviatingWithTildeInPath]
                 action:nil keyEquivalent:@""];
             [item setTag:((uint32_t)base + i)];
@@ -743,7 +742,7 @@ bool MenuBarIsVisible() {
 
 /* Save/load */
 
-bool SolveSpace::GetOpenFile(std::string *file, const std::string &defExtension,
+bool SolveSpace::GetOpenFile(Platform::Path *filename, const std::string &defExtension,
                              const FileFilter ssFilters[]) {
     NSOpenPanel *panel = [NSOpenPanel openPanel];
     NSMutableArray *filters = [[NSMutableArray alloc] init];
@@ -756,8 +755,9 @@ bool SolveSpace::GetOpenFile(std::string *file, const std::string &defExtension,
     [panel setAllowedFileTypes:filters];
 
     if([panel runModal] == NSFileHandlingPanelOKButton) {
-        *file = [[NSFileManager defaultManager]
-            fileSystemRepresentationWithPath:[[panel URL] path]];
+        *filename = Platform::Path::From(
+            [[NSFileManager defaultManager]
+                fileSystemRepresentationWithPath:[[panel URL] path]]);
         return true;
     } else {
         return false;
@@ -784,7 +784,7 @@ bool SolveSpace::GetOpenFile(std::string *file, const std::string &defExtension,
 }
 @end
 
-bool SolveSpace::GetSaveFile(std::string *file, const std::string &defExtension,
+bool SolveSpace::GetSaveFile(Platform::Path *filename, const std::string &defExtension,
                              const FileFilter ssFilters[]) {
     NSSavePanel *panel = [NSSavePanel savePanel];
 
@@ -823,22 +823,23 @@ bool SolveSpace::GetSaveFile(std::string *file, const std::string &defExtension,
     }
     [button selectItemAtIndex:extensionIndex];
 
-    if(file->empty()) {
+    if(filename->IsEmpty()) {
         [panel setNameFieldStringValue:
             [Wrap(_("untitled"))
                 stringByAppendingPathExtension:[extensions objectAtIndex:extensionIndex]]];
     } else {
         [panel setDirectoryURL:
-            [NSURL fileURLWithPath:Wrap(Dirname(*file))
+            [NSURL fileURLWithPath:Wrap(filename->Parent().raw)
                    isDirectory:NO]];
         [panel setNameFieldStringValue:
-            [Wrap(Basename(*file, /*stripExtension=*/true))
+            [Wrap(filename->FileStem())
                 stringByAppendingPathExtension:[extensions objectAtIndex:extensionIndex]]];
     }
 
     if([panel runModal] == NSFileHandlingPanelOKButton) {
-        *file = [[NSFileManager defaultManager]
-            fileSystemRepresentationWithPath:[[panel URL] path]];
+        *filename = Platform::Path::From(
+            [[NSFileManager defaultManager]
+                fileSystemRepresentationWithPath:[[panel URL] path]]);
         return true;
     } else {
         return false;
@@ -847,11 +848,11 @@ bool SolveSpace::GetSaveFile(std::string *file, const std::string &defExtension,
 
 SolveSpace::DialogChoice SolveSpace::SaveFileYesNoCancel() {
     NSAlert *alert = [[NSAlert alloc] init];
-    if(!std::string(SolveSpace::SS.saveFile).empty()) {
+    if(!SolveSpace::SS.saveFile.IsEmpty()) {
         [alert setMessageText:
             [[@"Do you want to save the changes you made to the sketch “"
              stringByAppendingString:
-                [Wrap(SolveSpace::SS.saveFile)
+                [Wrap(SolveSpace::SS.saveFile.raw)
                     stringByAbbreviatingWithTildeInPath]]
              stringByAppendingString:@"”?"]];
     } else {
@@ -891,10 +892,10 @@ SolveSpace::DialogChoice SolveSpace::LoadAutosaveYesNo() {
 }
 
 SolveSpace::DialogChoice SolveSpace::LocateImportedFileYesNoCancel(
-                            const std::string &filename, bool canCancel) {
+                            const Platform::Path &filename, bool canCancel) {
     NSAlert *alert = [[NSAlert alloc] init];
     [alert setMessageText:
-        Wrap("The linked file “" + filename + "” is not present.")];
+        Wrap("The linked file “" + filename.raw + "” is not present.")];
     [alert setInformativeText:
         Wrap(_("Do you want to locate it manually?\n"
                "If you select “No”, any geometry that depends on "
@@ -1148,8 +1149,8 @@ void SolveSpace::OpenWebsite(const char *url) {
         [NSURL URLWithString:[NSString stringWithUTF8String:url]]];
 }
 
-std::vector<std::string> SolveSpace::GetFontFiles() {
-    std::vector<std::string> fonts;
+std::vector<SolveSpace::Platform::Path> SolveSpace::GetFontFiles() {
+    std::vector<SolveSpace::Platform::Path> fonts;
 
     NSArray *fontNames = [[NSFontManager sharedFontManager] availableFonts];
     for(NSString *fontName in fontNames) {
@@ -1157,8 +1158,9 @@ std::vector<std::string> SolveSpace::GetFontFiles() {
             CTFontDescriptorCreateWithNameAndSize ((__bridge CFStringRef)fontName, 10.0);
         CFURLRef url = (CFURLRef)CTFontDescriptorCopyAttribute(fontRef, kCTFontURLAttribute);
         NSString *fontPath = [NSString stringWithString:[(NSURL *)CFBridgingRelease(url) path]];
-        fonts.push_back([[NSFileManager defaultManager]
-            fileSystemRepresentationWithPath:fontPath]);
+        fonts.push_back(
+            Platform::Path::From([[NSFileManager defaultManager]
+                fileSystemRepresentationWithPath:fontPath]));
     }
 
     return fonts;
@@ -1191,7 +1193,8 @@ std::vector<std::string> SolveSpace::GetFontFiles() {
 }
 
 - (BOOL)application:(NSApplication *)theApplication openFile:(NSString *)filename {
-    return SolveSpace::SS.OpenFile(SolveSpace::PathFromCurrentDirectory([filename UTF8String]));
+    SolveSpace::Platform::Path path = SolveSpace::Platform::Path::From([filename UTF8String]);
+    return SolveSpace::SS.Load(path.Expand(/*fromCurrentDirectory=*/true));
 }
 
 - (IBAction)preferences:(id)sender {
diff --git a/src/platform/gtkmain.cpp b/src/platform/gtkmain.cpp
index 3d456a2..a1e5bca 100644
--- a/src/platform/gtkmain.cpp
+++ b/src/platform/gtkmain.cpp
@@ -602,8 +602,8 @@ void PaintGraphics(void) {
     Glib::MainContext::get_default()->iteration(false);
 }
 
-void SetCurrentFilename(const std::string &filename) {
-    GW->set_title(Title(filename.empty() ? C_("title", "(new sketch)") : filename.c_str()));
+void SetCurrentFilename(const Platform::Path &filename) {
+    GW->set_title(Title(filename.IsEmpty() ? C_("title", "(new sketch)") : filename.raw.c_str()));
 }
 
 void ToggleFullScreen(void) {
@@ -907,16 +907,16 @@ static void RefreshRecentMenu(Command cmd, Command base) {
     Gtk::Menu *menu = new Gtk::Menu;
     recent->set_submenu(*menu);
 
-    if(RecentFile[0].empty()) {
+    if(RecentFile[0].IsEmpty()) {
         Gtk::MenuItem *placeholder = new Gtk::MenuItem(_("(no recent files)"));
         placeholder->set_sensitive(false);
         menu->append(*placeholder);
     } else {
         for(size_t i = 0; i < MAX_RECENT; i++) {
-            if(RecentFile[i].empty())
+            if(RecentFile[i].IsEmpty())
                 break;
 
-            RecentMenuItem *item = new RecentMenuItem(RecentFile[i], (uint32_t)base + i);
+            RecentMenuItem *item = new RecentMenuItem(RecentFile[i].raw, (uint32_t)base + i);
             menu->append(*item);
         }
     }
@@ -962,10 +962,10 @@ static std::string ConvertFilters(std::string active, const FileFilter ssFilters
     return active;
 }
 
-bool GetOpenFile(std::string *filename, const std::string &activeOrEmpty,
+bool GetOpenFile(Platform::Path *filename, const std::string &activeOrEmpty,
                  const FileFilter filters[]) {
     Gtk::FileChooserDialog chooser(*GW, Title(C_("title", "Open File")));
-    chooser.set_filename(*filename);
+    chooser.set_filename(filename->raw);
     chooser.add_button(_("_Cancel"), Gtk::RESPONSE_CANCEL);
     chooser.add_button(_("_Open"), Gtk::RESPONSE_OK);
     chooser.set_current_folder(CnfThawString("", "FileChooserPath"));
@@ -974,7 +974,7 @@ bool GetOpenFile(std::string *filename, const std::string &activeOrEmpty,
 
     if(chooser.run() == Gtk::RESPONSE_OK) {
         CnfFreezeString(chooser.get_current_folder(), "FileChooserPath");
-        *filename = chooser.get_filename();
+        *filename = Platform::Path::From(chooser.get_filename());
         return true;
     } else {
         return false;
@@ -1000,17 +1000,11 @@ static void ChooserFilterChanged(Gtk::FileChooserDialog *chooser)
     if(extension.length() > 2 && extension.substr(0, 2) == "*.")
         extension = extension.substr(2, extension.length() - 2);
 
-    std::string basename = Basename(chooser->get_filename());
-    int dot = basename.rfind('.');
-    if(dot >= 0) {
-        basename.replace(dot + 1, basename.length() - dot - 1, extension);
-        chooser->set_current_name(basename);
-    } else {
-        chooser->set_current_name(basename + "." + extension);
-    }
+    Platform::Path path = Platform::Path::From(chooser->get_filename());
+    chooser->set_current_name(path.WithExtension(extension).FileName());
 }
 
-bool GetSaveFile(std::string *filename, const std::string &defExtension,
+bool GetSaveFile(Platform::Path *filename, const std::string &defExtension,
                  const FileFilter filters[]) {
     Gtk::FileChooserDialog chooser(*GW, Title(C_("title", "Save File")),
                                    Gtk::FILE_CHOOSER_ACTION_SAVE);
@@ -1020,13 +1014,12 @@ bool GetSaveFile(std::string *filename, const std::string &defExtension,
 
     std::string activeExtension = ConvertFilters(defExtension, filters, &chooser);
 
-    if(filename->empty()) {
+    if(filename->IsEmpty()) {
         chooser.set_current_folder(CnfThawString("", "FileChooserPath"));
         chooser.set_current_name(std::string(_("untitled")) + "." + activeExtension);
     } else {
-        chooser.set_current_folder(Dirname(*filename));
-        chooser.set_current_name(Basename(*filename, /*stripExtension=*/true) +
-                                 "." + activeExtension);
+        chooser.set_current_folder(filename->Parent().raw);
+        chooser.set_current_name(filename->WithExtension(activeExtension).FileName());
     }
 
     /* Gtk's dialog doesn't change the extension when you change the filter,
@@ -1036,7 +1029,7 @@ bool GetSaveFile(std::string *filename, const std::string &defExtension,
 
     if(chooser.run() == Gtk::RESPONSE_OK) {
         CnfFreezeString(chooser.get_current_folder(), "FileChooserPath");
-        *filename = chooser.get_filename();
+        *filename = Platform::Path::From(chooser.get_filename());
         return true;
     } else {
         return false;
@@ -1087,10 +1080,10 @@ DialogChoice LoadAutosaveYesNo(void) {
     }
 }
 
-DialogChoice LocateImportedFileYesNoCancel(const std::string &filename,
+DialogChoice LocateImportedFileYesNoCancel(const Platform::Path &filename,
                                            bool canCancel) {
     Glib::ustring message =
-        "The linked file " + filename + " is not present.\n\n"
+        "The linked file " + filename.raw + " is not present.\n\n"
         "Do you want to locate it manually?\n\n"
         "If you select \"No\", any geometry that depends on "
         "the missing file will be removed.";
@@ -1318,8 +1311,8 @@ void OpenWebsite(const char *url) {
 }
 
 /* fontconfig is already initialized by GTK */
-std::vector<std::string> GetFontFiles() {
-    std::vector<std::string> fonts;
+std::vector<Platform::Path> GetFontFiles() {
+    std::vector<Platform::Path> fonts;
 
     FcPattern   *pat = FcPatternCreate();
     FcObjectSet *os  = FcObjectSetBuild(FC_FILE, (char *)0);
@@ -1327,8 +1320,7 @@ std::vector<std::string> GetFontFiles() {
 
     for(int i = 0; i < fs->nfont; i++) {
         FcChar8 *filenameFC = FcPatternFormat(fs->fonts[i], (const FcChar8*) "%{file}");
-        std::string filename = (char*) filenameFC;
-        fonts.push_back(filename);
+        fonts.push_back(Platform::Path::From((const char *)filenameFC));
         FcStrFree(filenameFC);
     }
 
@@ -1463,7 +1455,8 @@ int main(int argc, char** argv) {
         }
 
         /* Make sure the argument is valid UTF-8. */
-        SS.OpenFile(PathFromCurrentDirectory(Glib::ustring(argv[1])));
+        Glib::ustring arg(argv[1]);
+        SS.Load(Platform::Path::From(arg).Expand(/*fromCurrentDirectory=*/true));
     }
 
     main.run(*GW);
diff --git a/src/platform/headless.cpp b/src/platform/headless.cpp
index efb1236..f37edf5 100644
--- a/src/platform/headless.cpp
+++ b/src/platform/headless.cpp
@@ -148,7 +148,7 @@ void PaintGraphics() {
     cairo_destroy(context);
 }
 
-void SetCurrentFilename(const std::string &filename) {
+void SetCurrentFilename(const Platform::Path &filename) {
 }
 void ToggleFullScreen() {
 }
@@ -210,11 +210,11 @@ bool TextEditControlIsVisible() {
 // Dialogs
 //-----------------------------------------------------------------------------
 
-bool GetOpenFile(std::string *filename, const std::string &activeOrEmpty,
+bool GetOpenFile(Platform::Path *filename, const std::string &activeOrEmpty,
                  const FileFilter filters[]) {
     ssassert(false, "Not implemented");
 }
-bool GetSaveFile(std::string *filename, const std::string &activeOrEmpty,
+bool GetSaveFile(Platform::Path *filename, const std::string &activeOrEmpty,
                  const FileFilter filters[]) {
     ssassert(false, "Not implemented");
 }
@@ -224,7 +224,7 @@ DialogChoice SaveFileYesNoCancel() {
 DialogChoice LoadAutosaveYesNo() {
     ssassert(false, "Not implemented");
 }
-DialogChoice LocateImportedFileYesNoCancel(const std::string &filename,
+DialogChoice LocateImportedFileYesNoCancel(const Platform::Path &filename,
                                            bool canCancel) {
     ssassert(false, "Not implemented");
 }
@@ -240,8 +240,8 @@ void OpenWebsite(const char *url) {
 // Resources
 //-----------------------------------------------------------------------------
 
-std::vector<std::string> fontFiles;
-std::vector<std::string> GetFontFiles() {
+std::vector<Platform::Path> fontFiles;
+std::vector<Platform::Path> GetFontFiles() {
     return fontFiles;
 }
 
diff --git a/src/platform/platform.cpp b/src/platform/platform.cpp
index e61f37e..3e728bc 100644
--- a/src/platform/platform.cpp
+++ b/src/platform/platform.cpp
@@ -1,24 +1,82 @@
 //-----------------------------------------------------------------------------
-// Common platform-dependent functionality.
+// Platform-dependent functionality.
 //
 // Copyright 2017 whitequark
 //-----------------------------------------------------------------------------
 #if defined(__APPLE__)
+// Include Apple headers before solvespace.h to avoid identifier clashes.
 #   include <CoreFoundation/CFString.h>
+#   include <CoreFoundation/CFURL.h>
+#   include <CoreFoundation/CFBundle.h>
 #endif
 #include "solvespace.h"
+#include "config.h"
 #if defined(WIN32)
+// Conversely, include Microsoft headers after solvespace.h to avoid clashes.
 #   include <windows.h>
 #else
 #   include <unistd.h>
+#   include <sys/stat.h>
 #endif
 
 namespace SolveSpace {
-
-using namespace Platform;
+namespace Platform {
 
 //-----------------------------------------------------------------------------
-// Utility functions.
+// UTF-8 ⟷ UTF-16 conversion, on Windows.
+//-----------------------------------------------------------------------------
+
+#if defined(WIN32)
+
+std::string Narrow(const wchar_t *in)
+{
+    std::string out;
+    DWORD len = WideCharToMultiByte(CP_UTF8, 0, in, -1, NULL, 0, NULL, NULL);
+    out.resize(len - 1);
+    ssassert(WideCharToMultiByte(CP_UTF8, 0, in, -1, &out[0], len, NULL, NULL),
+             "Invalid UTF-16");
+    return out;
+}
+
+std::string Narrow(const std::wstring &in)
+{
+    if(in == L"") return "";
+
+    std::string out;
+    out.resize(WideCharToMultiByte(CP_UTF8, 0, &in[0], (int)in.length(),
+                                   NULL, 0, NULL, NULL));
+    ssassert(WideCharToMultiByte(CP_UTF8, 0, &in[0], (int)in.length(),
+                                 &out[0], (int)out.length(), NULL, NULL),
+             "Invalid UTF-16");
+    return out;
+}
+
+std::wstring Widen(const char *in)
+{
+    std::wstring out;
+    DWORD len = MultiByteToWideChar(CP_UTF8, 0, in, -1, NULL, 0);
+    out.resize(len - 1);
+    ssassert(MultiByteToWideChar(CP_UTF8, 0, in, -1, &out[0], len),
+             "Invalid UTF-8");
+    return out;
+}
+
+std::wstring Widen(const std::string &in)
+{
+    if(in == "") return L"";
+
+    std::wstring out;
+    out.resize(MultiByteToWideChar(CP_UTF8, 0, &in[0], (int)in.length(), NULL, 0));
+    ssassert(MultiByteToWideChar(CP_UTF8, 0, &in[0], (int)in.length(),
+                                 &out[0], (int)out.length()),
+             "Invalid UTF-8");
+    return out;
+}
+
+#endif
+
+//-----------------------------------------------------------------------------
+// Path utility functions.
 //-----------------------------------------------------------------------------
 
 static std::vector<std::string> Split(const std::string &joined, char separator) {
@@ -70,7 +128,7 @@ Path Path::From(std::string raw) {
 
 Path Path::CurrentDirectory() {
 #if defined(WIN32)
-    // On Windows, ssfopen needs an absolute UNC path proper, so get that.
+    // On Windows, OpenFile needs an absolute UNC path proper, so get that.
     std::wstring rawW;
     rawW.resize(GetCurrentDirectoryW(0, NULL));
     DWORD length = GetCurrentDirectoryW((int)rawW.length(), &rawW[0]);
@@ -267,6 +325,7 @@ static std::string FilesystemNormalize(const std::string &str) {
     std::string normalizedStr;
     normalizedStr.resize(CFStringGetMaximumSizeOfFileSystemRepresentation(cfStr));
     CFStringGetFileSystemRepresentation(cfStr, &normalizedStr[0], normalizedStr.size());
+    normalizedStr.erase(normalizedStr.find('\0'));
     return normalizedStr;
 #else
     return str;
@@ -332,4 +391,182 @@ std::string Path::ToPortable() const {
     return Concat(Split(raw, SEPARATOR), '/');
 }
 
+//-----------------------------------------------------------------------------
+// File manipulation.
+//-----------------------------------------------------------------------------
+
+FILE *OpenFile(const Platform::Path &filename, const char *mode) {
+    ssassert(filename.raw.length() == strlen(filename.raw.c_str()),
+             "Unexpected null byte in middle of a path");
+#if defined(WIN32)
+    return _wfopen(Widen(filename.Expand().raw).c_str(), Widen(mode).c_str());
+#else
+    return fopen(filename.raw.c_str(), mode);
+#endif
+}
+
+void RemoveFile(const Platform::Path &filename) {
+    ssassert(filename.raw.length() == strlen(filename.raw.c_str()),
+             "Unexpected null byte in middle of a path");
+#if defined(WIN32)
+    _wremove(Widen(filename.Expand().raw).c_str());
+#else
+    remove(filename.raw.c_str());
+#endif
+}
+
+bool ReadFile(const Platform::Path &filename, std::string *data) {
+    FILE *f = OpenFile(filename, "rb");
+    if(f == NULL) return false;
+
+    fseek(f, 0, SEEK_END);
+    data->resize(ftell(f));
+    fseek(f, 0, SEEK_SET);
+    fread(&(*data)[0], 1, data->size(), f);
+    fclose(f);
+
+    return true;
+}
+
+bool WriteFile(const Platform::Path &filename, const std::string &data) {
+    FILE *f = OpenFile(filename, "wb");
+    if(f == NULL) return false;
+
+    fwrite(&data[0], 1, data.size(), f);
+    fclose(f);
+
+    return true;
+}
+
+//-----------------------------------------------------------------------------
+// Loading resources, on Windows.
+//-----------------------------------------------------------------------------
+
+#if defined(WIN32)
+
+const void *LoadResource(const std::string &name, size_t *size) {
+    HRSRC hres = FindResourceW(NULL, Widen(name).c_str(), RT_RCDATA);
+    ssassert(hres != NULL, "Cannot find resource");
+    HGLOBAL res = ::LoadResource(NULL, hres);
+    ssassert(res != NULL, "Cannot load resource");
+
+    *size = SizeofResource(NULL, hres);
+    return LockResource(res);
+}
+
+#endif
+
+//-----------------------------------------------------------------------------
+// Loading resources, on *nix.
+//-----------------------------------------------------------------------------
+
+#if defined(__APPLE__)
+
+static Platform::Path PathFromCFURL(CFURLRef cfUrl) {
+    Path path;
+    CFStringRef cfPath = CFURLCopyFileSystemPath(cfUrl, kCFURLPOSIXPathStyle);
+    path.raw.resize(CFStringGetMaximumSizeOfFileSystemRepresentation(cfPath));
+    CFStringGetFileSystemRepresentation(cfPath, &path.raw[0], path.raw.size());
+    path.raw.erase(path.raw.find('\0'));
+    CFRelease(cfPath);
+    return path;
+}
+
+static Platform::Path ResourcePath(const std::string &name) {
+    Path path;
+
+    // First, try to get the URL from the bundle.
+    CFStringRef cfName = CFStringCreateWithCString(kCFAllocatorDefault, name.c_str(),
+                                                   kCFStringEncodingUTF8);
+    CFURLRef cfUrl = CFBundleCopyResourceURL(CFBundleGetMainBundle(), cfName, NULL, NULL);
+    if(cfUrl != NULL) {
+        path = PathFromCFURL(cfUrl);
+        CFRelease(cfUrl);
+    }
+    CFRelease(cfName);
+
+    if(!path.IsEmpty()) return path;
+
+    // If that failed, it means we aren't running from the bundle.
+    // Reference off the executable path, then.
+    cfUrl = CFBundleCopyExecutableURL(CFBundleGetMainBundle());
+    if(cfUrl != NULL) {
+        path = PathFromCFURL(cfUrl).Parent().Parent().Join("res");
+        path = path.Join(Path::FromPortable(name));
+        CFRelease(cfUrl);
+    }
+
+    return path;
+}
+
+#elif !defined(WIN32)
+
+#    if defined(__linux__)
+static const char *selfSymlink = "/proc/self/exe";
+#    elif defined(__NetBSD__)
+static const char *selfSymlink = "/proc/curproc/exe"
+#    elif defined(__OpenBSD__) || defined(__FreeBSD__)
+static const char *selfSymlink = "/proc/curproc/file";
+#    else
+static const char *selfSymlink = "";
+#    endif
+
+static Platform::Path FindLocalResourceDir() {
+    // Find out the path to the running binary.
+    Platform::Path selfPath;
+    char *expandedSelfPath = realpath(selfSymlink, NULL);
+    if(expandedSelfPath != NULL) {
+        selfPath = Path::From(expandedSelfPath);
+    }
+    free(expandedSelfPath);
+
+    Platform::Path resourceDir;
+    if(selfPath.IsEmpty()) {
+        // We don't know how to find the local resource directory on this platform,
+        // so use the global one (by returning an empty string).
+        return Path::From(UNIX_DATADIR);
+    } else {
+        resourceDir = selfPath.Parent().Parent().Join("res");
+    }
+
+    struct stat st;
+    if(stat(resourceDir.raw.c_str(), &st) != -1) {
+        // An executable-adjacent resource directory exists, good.
+        return resourceDir;
+    }
+
+    // No executable-adjacent resource directory; use the one from compile-time prefix.
+    return Path::From(UNIX_DATADIR);
+}
+
+static Platform::Path ResourcePath(const std::string &name) {
+    static Platform::Path resourceDir;
+    if(resourceDir.IsEmpty()) {
+        resourceDir = FindLocalResourceDir();
+    }
+
+    return resourceDir.Join(Path::FromPortable(name));
+}
+
+#endif
+
+#if !defined(WIN32)
+
+const void *LoadResource(const std::string &name, size_t *size) {
+    static std::map<std::string, std::string> cache;
+
+    auto it = cache.find(name);
+    if(it == cache.end()) {
+        ssassert(ReadFile(ResourcePath(name), &cache[name]), "Cannot read resource");
+        it = cache.find(name);
+    }
+
+    const std::string &content = (*it).second;
+    *size = content.size();
+    return (const void*)content.data();
+}
+
+#endif
+
+}
 }
diff --git a/src/platform/platform.h b/src/platform/platform.h
index 3768885..06ae19f 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -1,5 +1,5 @@
 //-----------------------------------------------------------------------------
-// Common platform-dependent functionality.
+// Platform-dependent functionality.
 //
 // Copyright 2017 whitequark
 //-----------------------------------------------------------------------------
@@ -9,6 +9,14 @@
 
 namespace Platform {
 
+// UTF-8 ⟷ UTF-16 conversion, for Windows.
+#if defined(WIN32)
+std::string Narrow(const wchar_t *s);
+std::wstring Widen(const char *s);
+std::string Narrow(const std::wstring &s);
+std::wstring Widen(const std::string &s);
+#endif
+
 // A filesystem path, respecting the conventions of the current platform.
 // Transformation functions return an empty path on error.
 class Path {
@@ -46,6 +54,15 @@ struct PathLess {
     bool operator()(const Path &a, const Path &b) const { return a.raw < b.raw; }
 };
 
+// File manipulation functions.
+FILE *OpenFile(const Platform::Path &filename, const char *mode);
+bool ReadFile(const Platform::Path &filename, std::string *data);
+bool WriteFile(const Platform::Path &filename, const std::string &data);
+void RemoveFile(const Platform::Path &filename);
+
+// Resource loading function.
+const void *LoadResource(const std::string &name, size_t *size);
+
 }
 
 #endif
diff --git a/src/platform/unixutil.cpp b/src/platform/unixutil.cpp
index cae4595..125623c 100644
--- a/src/platform/unixutil.cpp
+++ b/src/platform/unixutil.cpp
@@ -7,18 +7,8 @@
 // Copyright 2008-2013 Jonathan Westhues.
 // Copyright 2013 Daniel Richard G. <skunk@iSKUNK.ORG>
 //-----------------------------------------------------------------------------
-#include <unistd.h>
-#include <sys/stat.h>
 #include <execinfo.h>
-#ifdef __APPLE__
-#   include <strings.h> // for strcasecmp
-#   include <CoreFoundation/CFString.h>
-#   include <CoreFoundation/CFURL.h>
-#   include <CoreFoundation/CFBundle.h>
-#endif
-
 #include "solvespace.h"
-#include "config.h"
 
 namespace SolveSpace {
 
@@ -59,143 +49,6 @@ void assert_failure(const char *file, unsigned line, const char *function,
     abort();
 }
 
-bool PathEqual(const std::string &a, const std::string &b)
-{
-#if defined(__APPLE__)
-    // Case-sensitivity is actually per-volume on OS X,
-    // but it is tedious to implement and test for little benefit.
-    return !strcasecmp(a.c_str(), b.c_str());
-#else
-    return a == b;
-#endif
-}
-
-std::string PathSepPlatformToUnix(const std::string &filename)
-{
-    return filename;
-}
-
-std::string PathSepUnixToPlatform(const std::string &filename)
-{
-    return filename;
-}
-
-std::string PathFromCurrentDirectory(const std::string &relFilename)
-{
-    // On Unix we can just pass this to ssfopen directly.
-    return relFilename;
-}
-
-FILE *ssfopen(const std::string &filename, const char *mode)
-{
-    ssassert(filename.length() == strlen(filename.c_str()),
-             "Unexpected null byte in middle of a path");
-    return fopen(filename.c_str(), mode);
-}
-
-void ssremove(const std::string &filename)
-{
-    ssassert(filename.length() == strlen(filename.c_str()),
-             "Unexpected null byte in middle of a path");
-    remove(filename.c_str());
-}
-
-static std::string ExpandPath(std::string path) {
-    char *expanded_c_path = realpath(path.c_str(), NULL);
-    if(expanded_c_path == NULL) return "";
-
-    std::string expanded_path = expanded_c_path;
-    free(expanded_c_path);
-    return expanded_path;
-}
-
-static const std::string &FindLocalResourceDir() {
-    static std::string resourceDir;
-    static bool checked;
-
-    if(checked) return resourceDir;
-    checked = true;
-
-    // Getting path to your own executable is a total portability disaster.
-    // Good job *nix OSes; you're basically all awful here.
-    std::string selfPath;
-#if defined(__linux__)
-    selfPath = "/proc/self/exe";
-#elif defined(__NetBSD__)
-    selfPath = "/proc/curproc/exe"
-#elif defined(__OpenBSD__) || defined(__FreeBSD__)
-    selfPath = "/proc/curproc/file";
-#elif defined(__APPLE__)
-    CFURLRef cfUrl =
-        CFBundleCopyExecutableURL(CFBundleGetMainBundle());
-    CFStringRef cfPath = CFURLCopyFileSystemPath(cfUrl, kCFURLPOSIXPathStyle);
-    selfPath.resize(CFStringGetLength(cfPath) + 1); // reserve space for NUL
-    ssassert(CFStringGetCString(cfPath, &selfPath[0], selfPath.size(), kCFStringEncodingUTF8),
-             "Cannot convert CFString to C string");
-    selfPath.resize(selfPath.size() - 1);
-    CFRelease(cfUrl);
-    CFRelease(cfPath);
-#else
-    // We don't know how to find the local resource directory on this platform,
-    // so use the global one (by returning an empty string).
-    return resourceDir;
-#endif
-
-    resourceDir = ExpandPath(selfPath);
-    if(!resourceDir.empty()) {
-        resourceDir.erase(resourceDir.rfind('/'));
-        resourceDir += "/../res";
-        resourceDir = ExpandPath(resourceDir);
-    }
-    if(!resourceDir.empty()) {
-        struct stat st;
-        if(stat(resourceDir.c_str(), &st)) {
-            // We looked at the path where the local resource directory ought to be,
-            // but there isn't one, so use the global one.
-            resourceDir = "";
-        }
-    }
-    return resourceDir;
-}
-
-const void *LoadResource(const std::string &name, size_t *size) {
-    static std::map<std::string, std::string> cache;
-
-    auto it = cache.find(name);
-    if(it == cache.end()) {
-        const std::string &resourceDir = FindLocalResourceDir();
-
-        std::string path;
-        if(resourceDir.empty()) {
-#if defined(__APPLE__)
-            CFStringRef cfName =
-                CFStringCreateWithCString(kCFAllocatorDefault, name.c_str(),
-                                          kCFStringEncodingUTF8);
-            CFURLRef cfUrl =
-                CFBundleCopyResourceURL(CFBundleGetMainBundle(), cfName, NULL, NULL);
-            CFStringRef cfPath = CFURLCopyFileSystemPath(cfUrl, kCFURLPOSIXPathStyle);
-            path.resize(CFStringGetLength(cfPath) + 1); // reserve space for NUL
-            ssassert(CFStringGetCString(cfPath, &path[0], path.size(), kCFStringEncodingUTF8),
-                     "Cannot convert CFString to C string");
-            path.resize(path.size() - 1);
-            CFRelease(cfName);
-            CFRelease(cfUrl);
-            CFRelease(cfPath);
-#else
-            path = (UNIX_DATADIR "/") + name;
-#endif
-        } else {
-            path = resourceDir + "/" + name;
-        }
-
-        ssassert(ReadFile(path, &cache[name]), "Cannot read resource");
-        it = cache.find(name);
-    }
-
-    *size = (*it).second.size();
-    return static_cast<const void *>(&(*it).second[0]);
-}
-
 //-----------------------------------------------------------------------------
 // A separate heap, on which we allocate expressions. Maybe a bit faster,
 // since fragmentation is less of a concern, and it also makes it possible
diff --git a/src/platform/w32main.cpp b/src/platform/w32main.cpp
index 9067a34..ab031f3 100644
--- a/src/platform/w32main.cpp
+++ b/src/platform/w32main.cpp
@@ -32,6 +32,9 @@
 #include <EGL/egl.h>
 #endif
 
+using Platform::Narrow;
+using Platform::Widen;
+
 HINSTANCE Instance;
 
 HWND TextWnd;
@@ -443,9 +446,9 @@ static void ThawWindowPos(HWND hwnd, const std::string &name)
         ShowWindow(hwnd, SW_MAXIMIZE);
 }
 
-void SolveSpace::SetCurrentFilename(const std::string &filename) {
+void SolveSpace::SetCurrentFilename(const Platform::Path &filename) {
     SetWindowTextW(GraphicsWnd,
-        Title(filename.empty() ? C_("title", "(new sketch)") : filename).c_str());
+        Title(filename.IsEmpty() ? C_("title", "(new sketch)") : filename.raw).c_str());
 }
 
 void SolveSpace::SetMousePointerToHand(bool yes) {
@@ -1091,7 +1094,7 @@ static std::string ConvertFilters(const FileFilter ssFilters[]) {
     return filter;
 }
 
-static bool OpenSaveFile(bool isOpen, std::string *filename, const std::string &defExtension,
+static bool OpenSaveFile(bool isOpen, Platform::Path *filename, const std::string &defExtension,
                          const FileFilter filters[]) {
     std::string activeExtension = defExtension;
     if(activeExtension == "") {
@@ -1099,11 +1102,10 @@ static bool OpenSaveFile(bool isOpen, std::string *filename, const std::string &
     }
 
     std::wstring initialFilenameW;
-    if(filename->empty()) {
+    if(filename->IsEmpty()) {
         initialFilenameW = Widen("untitled");
     } else {
-        initialFilenameW = Widen(Dirname(*filename) + PATH_SEP +
-                                 Basename(*filename, /*stripExtension=*/true));
+        initialFilenameW = Widen(filename->Parent().Join(filename->FileStem()).raw);
     }
     std::wstring selPatternW = Widen(ConvertFilters(filters));
     std::wstring defExtensionW = Widen(defExtension);
@@ -1140,17 +1142,17 @@ static bool OpenSaveFile(bool isOpen, std::string *filename, const std::string &
     EnableWindow(GraphicsWnd, true);
     SetForegroundWindow(GraphicsWnd);
 
-    if(r) *filename = Narrow(filenameC);
+    if(r) *filename = Platform::Path::From(Narrow(filenameC));
     return r ? true : false;
 }
 
-bool SolveSpace::GetOpenFile(std::string *filename, const std::string &defExtension,
+bool SolveSpace::GetOpenFile(Platform::Path *filename, const std::string &defExtension,
                              const FileFilter filters[])
 {
     return OpenSaveFile(/*isOpen=*/true, filename, defExtension, filters);
 }
 
-bool SolveSpace::GetSaveFile(std::string *filename, const std::string &defExtension,
+bool SolveSpace::GetSaveFile(Platform::Path *filename, const std::string &defExtension,
                              const FileFilter filters[])
 {
     return OpenSaveFile(/*isOpen=*/false, filename, defExtension, filters);
@@ -1206,13 +1208,13 @@ DialogChoice SolveSpace::LoadAutosaveYesNo()
     }
 }
 
-DialogChoice SolveSpace::LocateImportedFileYesNoCancel(const std::string &filename,
+DialogChoice SolveSpace::LocateImportedFileYesNoCancel(const Platform::Path &filename,
                                                        bool canCancel) {
     EnableWindow(GraphicsWnd, false);
     EnableWindow(TextWnd, false);
 
     std::string message =
-        "The linked file " + filename + " is not present.\n\n"
+        "The linked file " + filename.raw + " is not present.\n\n"
         "Do you want to locate it manually?\n\n"
         "If you select \"No\", any geometry that depends on "
         "the missing file will be removed.";
@@ -1236,17 +1238,18 @@ DialogChoice SolveSpace::LocateImportedFileYesNoCancel(const std::string &filena
     }
 }
 
-std::vector<std::string> SolveSpace::GetFontFiles() {
-    std::vector<std::string> fonts;
+std::vector<Platform::Path> SolveSpace::GetFontFiles() {
+    std::vector<Platform::Path> fonts;
 
-    std::wstring fontsDir(MAX_PATH, '\0');
-    fontsDir.resize(GetWindowsDirectoryW(&fontsDir[0], fontsDir.length()));
-    fontsDir += L"\\fonts\\";
+    std::wstring fontsDirW(MAX_PATH, '\0');
+    fontsDirW.resize(GetWindowsDirectoryW(&fontsDirW[0], fontsDirW.length()));
+    fontsDirW += L"\\fonts\\";
+    Platform::Path fontsDir = Platform::Path::From(Narrow(fontsDirW));
 
     WIN32_FIND_DATA wfd;
-    HANDLE h = FindFirstFileW((fontsDir + L"*").c_str(), &wfd);
+    HANDLE h = FindFirstFileW((fontsDirW + L"*").c_str(), &wfd);
     while(h != INVALID_HANDLE_VALUE) {
-        fonts.push_back(Narrow(fontsDir) + Narrow(wfd.cFileName));
+        fonts.push_back(fontsDir.Join(Narrow(wfd.cFileName)));
         if(!FindNextFileW(h, &wfd)) break;
     }
 
@@ -1296,8 +1299,8 @@ static void DoRecent(HMENU m, Command base)
         ;
     int c = 0;
     for(size_t i = 0; i < MAX_RECENT; i++) {
-        if(!RecentFile[i].empty()) {
-            AppendMenuW(m, MF_STRING, (uint32_t)base + i, Widen(RecentFile[i]).c_str());
+        if(!RecentFile[i].IsEmpty()) {
+            AppendMenuW(m, MF_STRING, (uint32_t)base + i, Widen(RecentFile[i].raw).c_str());
             c++;
         }
     }
@@ -1540,7 +1543,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
     // A filename may have been specified on the command line; if so, then
     // strip any quotation marks, and make it absolute.
     if(args.size() >= 2) {
-        SS.OpenFile(PathFromCurrentDirectory(args[1]));
+        SS.Load(Platform::Path::From(args[1]).Expand(/*fromCurrentDirectory=*/true));
     }
 
     // Repaint one more time, after we've set everything up.
diff --git a/src/platform/w32util.cpp b/src/platform/w32util.cpp
index 26fef6e..4c4ae1f 100644
--- a/src/platform/w32util.cpp
+++ b/src/platform/w32util.cpp
@@ -46,130 +46,6 @@ void assert_failure(const char *file, unsigned line, const char *function,
 #endif
 }
 
-std::string Narrow(const wchar_t *in)
-{
-    std::string out;
-    DWORD len = WideCharToMultiByte(CP_UTF8, 0, in, -1, NULL, 0, NULL, NULL);
-    out.resize(len - 1);
-    ssassert(WideCharToMultiByte(CP_UTF8, 0, in, -1, &out[0], len, NULL, NULL),
-             "Invalid UTF-16");
-    return out;
-}
-
-std::string Narrow(const std::wstring &in)
-{
-    if(in == L"") return "";
-
-    std::string out;
-    out.resize(WideCharToMultiByte(CP_UTF8, 0, &in[0], (int)in.length(),
-                                   NULL, 0, NULL, NULL));
-    ssassert(WideCharToMultiByte(CP_UTF8, 0, &in[0], (int)in.length(),
-                                 &out[0], (int)out.length(), NULL, NULL),
-             "Invalid UTF-16");
-    return out;
-}
-
-std::wstring Widen(const char *in)
-{
-    std::wstring out;
-    DWORD len = MultiByteToWideChar(CP_UTF8, 0, in, -1, NULL, 0);
-    out.resize(len - 1);
-    ssassert(MultiByteToWideChar(CP_UTF8, 0, in, -1, &out[0], len),
-             "Invalid UTF-8");
-    return out;
-}
-
-std::wstring Widen(const std::string &in)
-{
-    if(in == "") return L"";
-
-    std::wstring out;
-    out.resize(MultiByteToWideChar(CP_UTF8, 0, &in[0], (int)in.length(), NULL, 0));
-    ssassert(MultiByteToWideChar(CP_UTF8, 0, &in[0], (int)in.length(),
-                                 &out[0], (int)out.length()),
-             "Invalid UTF-8");
-    return out;
-}
-
-bool PathEqual(const std::string &a, const std::string &b)
-{
-    // Case-sensitivity is actually per-volume on Windows,
-    // but it is tedious to implement and test for little benefit.
-    std::wstring wa = Widen(a), wb = Widen(b);
-    return std::equal(wa.begin(), wa.end(), wb.begin(), /*wb.end(),*/
-                [](wchar_t wca, wchar_t wcb) { return towlower(wca) == towlower(wcb); });
-
-}
-
-std::string PathSepPlatformToUnix(const std::string &filename)
-{
-    std::string result = filename;
-    for(size_t i = 0; i < result.length(); i++) {
-        if(result[i] == '\\')
-            result[i] = '/';
-    }
-    return result;
-}
-
-std::string PathSepUnixToPlatform(const std::string &filename)
-{
-    std::string result = filename;
-    for(size_t i = 0; i < result.length(); i++) {
-        if(result[i] == '/')
-            result[i] = '\\';
-    }
-    return result;
-}
-
-std::string PathFromCurrentDirectory(const std::string &relFilename)
-{
-    // On Windows, ssfopen needs an absolute UNC path proper, so get that.
-    std::wstring relFilenameW = Widen(relFilename);
-    std::wstring absFilenameW;
-    absFilenameW.resize(GetFullPathNameW(relFilenameW.c_str(), 0, NULL, NULL));
-    DWORD length = GetFullPathNameW(relFilenameW.c_str(), (int)absFilenameW.length(),
-                                    &absFilenameW[0], NULL);
-    ssassert(length != 0, "Expected GetFullPathName to succeed");
-    absFilenameW.resize(length);
-    return Narrow(absFilenameW);
-}
-
-static std::string MakeUNCFilename(const std::string &filename)
-{
-    // Prepend \\?\ UNC prefix unless already an UNC path.
-    // We never try to fopen paths that are not absolute or
-    // contain separators inappropriate for the platform;
-    // thus, it is always safe to prepend this prefix.
-    std::string uncFilename = filename;
-    if(uncFilename.substr(0, 2) != "\\\\")
-        uncFilename = "\\\\?\\" + uncFilename;
-    return uncFilename;
-}
-
-FILE *ssfopen(const std::string &filename, const char *mode)
-{
-    ssassert(filename.length() == strlen(filename.c_str()),
-             "Unexpected null byte in middle of a path");
-    return _wfopen(Widen(MakeUNCFilename(filename)).c_str(), Widen(mode).c_str());
-}
-
-void ssremove(const std::string &filename)
-{
-    ssassert(filename.length() == strlen(filename.c_str()),
-             "Unexpected null byte in middle of a path");
-    _wremove(Widen(filename).c_str());
-}
-
-const void *LoadResource(const std::string &name, size_t *size) {
-    HRSRC hres = FindResourceW(NULL, Widen(name).c_str(), RT_RCDATA);
-    ssassert(hres != NULL, "Cannot find resource");
-    HGLOBAL res = ::LoadResource(NULL, hres);
-    ssassert(res != NULL, "Cannot load resource");
-
-    *size = SizeofResource(NULL, hres);
-    return LockResource(res);
-}
-
 //-----------------------------------------------------------------------------
 // A separate heap, on which we allocate expressions. Maybe a bit faster,
 // since no fragmentation issues whatsoever, and it also makes it possible
@@ -231,7 +107,7 @@ std::vector<std::string> InitPlatform(int argc, char **argv) {
     LPWSTR *argvW = CommandLineToArgvW(GetCommandLineW(), &argcW);
     std::vector<std::string> args;
     for(int i = 0; i < argcW; i++) {
-        args.push_back(Narrow(argvW[i]));
+        args.push_back(Platform::Narrow(argvW[i]));
     }
     LocalFree(argvW);
     return args;
diff --git a/src/render/gl2shader.cpp b/src/render/gl2shader.cpp
index 5ce6b16..062bc89 100644
--- a/src/render/gl2shader.cpp
+++ b/src/render/gl2shader.cpp
@@ -58,7 +58,7 @@ Vector4f Vector4f::From(const RgbaColor &c) {
 
 static GLuint CompileShader(const std::string &res, GLenum type) {
     size_t size;
-    const char *resData = (const char *)LoadResource(res, &size);
+    const char *resData = (const char *)Platform::LoadResource(res, &size);
 
     // Sigh, here we go... We want to deploy to four platforms: Linux, Windows, OS X, mobile+web.
     // These platforms are basically disjunctive in the OpenGL versions and profiles that they
diff --git a/src/resource.cpp b/src/resource.cpp
index 1362db3..c1501f3 100644
--- a/src/resource.cpp
+++ b/src/resource.cpp
@@ -16,7 +16,7 @@ namespace SolveSpace {
 
 std::string LoadString(const std::string &name) {
     size_t size;
-    const void *data = LoadResource(name, &size);
+    const void *data = Platform::LoadResource(name, &size);
     std::string result(static_cast<const char *>(data), size);
 
     // When editing resources under Windows, Windows newlines may sneak in.
@@ -30,7 +30,7 @@ std::string LoadString(const std::string &name) {
 
 std::string LoadStringFromGzip(const std::string &name) {
     size_t deflatedSize;
-    const void *data = LoadResource(name, &deflatedSize);
+    const void *data = Platform::LoadResource(name, &deflatedSize);
 
     z_stream stream;
     stream.zalloc = Z_NULL;
@@ -62,7 +62,7 @@ std::string LoadStringFromGzip(const std::string &name) {
 
 std::shared_ptr<Pixmap> LoadPng(const std::string &name) {
     size_t size;
-    const void *data = LoadResource(name, &size);
+    const void *data = Platform::LoadResource(name, &size);
 
     std::shared_ptr<Pixmap> pixmap = Pixmap::FromPng(static_cast<const uint8_t *>(data), size);
     ssassert(pixmap != nullptr, "Cannot load pixmap");
@@ -263,8 +263,8 @@ exit:
     return nullptr;
 }
 
-std::shared_ptr<Pixmap> Pixmap::ReadPng(const std::string &filename, bool flip) {
-    FILE *f = ssfopen(filename.c_str(), "rb");
+std::shared_ptr<Pixmap> Pixmap::ReadPng(const Platform::Path &filename, bool flip) {
+    FILE *f = OpenFile(filename, "rb");
     if(!f) return NULL;
     std::shared_ptr<Pixmap> pixmap = ReadPng(f, flip);
     fclose(f);
@@ -318,8 +318,8 @@ exit:
     return false;
 }
 
-bool Pixmap::WritePng(const std::string &filename, bool flip) {
-    FILE *f = ssfopen(filename.c_str(), "wb");
+bool Pixmap::WritePng(const Platform::Path &filename, bool flip) {
+    FILE *f = OpenFile(filename, "wb");
     if(!f) return false;
     bool success = WritePng(f, flip);
     fclose(f);
diff --git a/src/resource.h b/src/resource.h
index 858e3b2..20bf6bd 100644
--- a/src/resource.h
+++ b/src/resource.h
@@ -12,12 +12,6 @@ class Point2d;
 class Pixmap;
 class Vector;
 
-// Only the following function is platform-specific.
-// It returns a pointer to resource contents that is aligned to at least
-// sizeof(void*) and has a global lifetime, or NULL if a resource with
-// the specified name does not exist.
-const void *LoadResource(const std::string &name, size_t *size);
-
 std::string LoadString(const std::string &name);
 std::string LoadStringFromGzip(const std::string &name);
 std::shared_ptr<Pixmap> LoadPng(const std::string &name);
@@ -36,9 +30,9 @@ public:
     static std::shared_ptr<Pixmap> FromPng(const uint8_t *data, size_t size, bool flip = false);
 
     static std::shared_ptr<Pixmap> ReadPng(FILE *f, bool flip = false);
-    static std::shared_ptr<Pixmap> ReadPng(const std::string &filename, bool flip = false);
+    static std::shared_ptr<Pixmap> ReadPng(const Platform::Path &filename, bool flip = false);
     bool WritePng(FILE *f, bool flip = false);
-    bool WritePng(const std::string &filename, bool flip = false);
+    bool WritePng(const Platform::Path &filename, bool flip = false);
 
     size_t GetBytesPerPixel() const;
     RgbaColor GetPixel(size_t x, size_t y) const;
diff --git a/src/sketch.h b/src/sketch.h
index 492da2e..35c2602 100644
--- a/src/sketch.h
+++ b/src/sketch.h
@@ -218,8 +218,7 @@ public:
     enum { REMAP_PRIME = 19477 };
     int remapCache[REMAP_PRIME];
 
-    std::string linkFile;
-    std::string linkFileRel;
+    Platform::Path linkFile;
     SMesh       impMesh;
     SShell      impShell;
     EntityList  impEntity;
diff --git a/src/solvespace.cpp b/src/solvespace.cpp
index f18c1be..67f7634 100644
--- a/src/solvespace.cpp
+++ b/src/solvespace.cpp
@@ -10,7 +10,7 @@
 SolveSpaceUI SolveSpace::SS = {};
 Sketch SolveSpace::SK = {};
 
-std::string SolveSpace::RecentFile[MAX_RECENT] = {};
+Platform::Path SolveSpace::RecentFile[MAX_RECENT] = {};
 
 void SolveSpaceUI::Init() {
 #if !defined(HEADLESS)
@@ -97,7 +97,7 @@ void SolveSpaceUI::Init() {
     showToolbar = CnfThawBool(true, "ShowToolbar");
     // Recent files menus
     for(size_t i = 0; i < MAX_RECENT; i++) {
-        RecentFile[i] = CnfThawString("", "RecentFile_" + std::to_string(i));
+        RecentFile[i] = Platform::Path::From(CnfThawString("", "RecentFile_" + std::to_string(i)));
     }
     RefreshRecentMenus();
     // Autosave timer
@@ -118,10 +118,10 @@ void SolveSpaceUI::Init() {
     AfterNewFile();
 }
 
-bool SolveSpaceUI::LoadAutosaveFor(const std::string &filename) {
-    std::string autosaveFile = filename + AUTOSAVE_SUFFIX;
+bool SolveSpaceUI::LoadAutosaveFor(const Platform::Path &filename) {
+    Platform::Path autosaveFile = filename.WithExtension(AUTOSAVE_EXT);
 
-    FILE *f = ssfopen(autosaveFile, "rb");
+    FILE *f = OpenFile(autosaveFile, "rb");
     if(!f)
         return false;
     fclose(f);
@@ -134,14 +134,14 @@ bool SolveSpaceUI::LoadAutosaveFor(const std::string &filename) {
     return false;
 }
 
-bool SolveSpaceUI::OpenFile(const std::string &filename) {
+bool SolveSpaceUI::Load(const Platform::Path &filename) {
     bool autosaveLoaded = LoadAutosaveFor(filename);
     bool fileLoaded = autosaveLoaded || LoadFromFile(filename, /*canCancel=*/true);
     if(fileLoaded) {
         saveFile = filename;
         AddToRecentList(filename);
     } else {
-        saveFile = "";
+        saveFile.Clear();
         NewFile();
     }
     AfterNewFile();
@@ -152,7 +152,7 @@ bool SolveSpaceUI::OpenFile(const std::string &filename) {
 void SolveSpaceUI::Exit() {
     // Recent files
     for(size_t i = 0; i < MAX_RECENT; i++)
-        CnfFreezeString(RecentFile[i], "RecentFile_" + std::to_string(i));
+        CnfFreezeString(RecentFile[i].raw, "RecentFile_" + std::to_string(i));
     // Model colors
     for(size_t i = 0; i < MODEL_COLORS; i++)
         CnfFreezeColor(modelColor[i], "ModelColor_" + std::to_string(i));
@@ -342,18 +342,18 @@ void SolveSpaceUI::AfterNewFile() {
     UpdateWindowTitle();
 }
 
-void SolveSpaceUI::RemoveFromRecentList(const std::string &filename) {
+void SolveSpaceUI::RemoveFromRecentList(const Platform::Path &filename) {
     int dest = 0;
     for(int src = 0; src < (int)MAX_RECENT; src++) {
-        if(filename != RecentFile[src]) {
+        if(!filename.Equals(RecentFile[src])) {
             if(src != dest) RecentFile[dest] = RecentFile[src];
             dest++;
         }
     }
-    while(dest < (int)MAX_RECENT) RecentFile[dest++].clear();
+    while(dest < (int)MAX_RECENT) RecentFile[dest++].Clear();
     RefreshRecentMenus();
 }
-void SolveSpaceUI::AddToRecentList(const std::string &filename) {
+void SolveSpaceUI::AddToRecentList(const Platform::Path &filename) {
     RemoveFromRecentList(filename);
 
     for(int src = MAX_RECENT - 2; src >= 0; src--) {
@@ -364,22 +364,19 @@ void SolveSpaceUI::AddToRecentList(const std::string &filename) {
 }
 
 bool SolveSpaceUI::GetFilenameAndSave(bool saveAs) {
-    std::string prevSaveFile = saveFile;
+    Platform::Path newSaveFile = saveFile;
 
-    if(saveAs || saveFile.empty()) {
-        if(!GetSaveFile(&saveFile, "", SlvsFileFilter)) return false;
-        // need to get new filename directly into saveFile, since that
-        // determines linkFileRel path
+    if(saveAs || saveFile.IsEmpty()) {
+        if(!GetSaveFile(&newSaveFile, "", SlvsFileFilter)) return false;
     }
 
-    if(SaveToFile(saveFile)) {
-        AddToRecentList(saveFile);
+    if(SaveToFile(newSaveFile)) {
+        AddToRecentList(newSaveFile);
         RemoveAutosave();
+        saveFile = newSaveFile;
         unsaved = false;
         return true;
     } else {
-        // don't store an invalid save filename
-        saveFile = prevSaveFile;
         return false;
     }
 }
@@ -388,16 +385,16 @@ bool SolveSpaceUI::Autosave()
 {
     SetAutosaveTimerFor(autosaveInterval);
 
-    if(!saveFile.empty() && unsaved)
-        return SaveToFile(saveFile + AUTOSAVE_SUFFIX);
+    if(!saveFile.IsEmpty() && unsaved)
+        return SaveToFile(saveFile.WithExtension(AUTOSAVE_EXT));
 
     return false;
 }
 
 void SolveSpaceUI::RemoveAutosave()
 {
-    std::string autosaveFile = saveFile + AUTOSAVE_SUFFIX;
-    ssremove(autosaveFile);
+    Platform::Path autosaveFile = saveFile.WithExtension(AUTOSAVE_EXT);
+    RemoveFile(autosaveFile);
 }
 
 bool SolveSpaceUI::OkayToStartNewFile() {
@@ -426,8 +423,8 @@ void SolveSpaceUI::MenuFile(Command id) {
        (uint32_t)id < ((uint32_t)Command::RECENT_OPEN+MAX_RECENT)) {
         if(!SS.OkayToStartNewFile()) return;
 
-        std::string newFile = RecentFile[(uint32_t)id - (uint32_t)Command::RECENT_OPEN];
-        SS.OpenFile(newFile);
+        Platform::Path newFile = RecentFile[(uint32_t)id - (uint32_t)Command::RECENT_OPEN];
+        SS.Load(newFile);
         return;
     }
 
@@ -435,7 +432,7 @@ void SolveSpaceUI::MenuFile(Command id) {
         case Command::NEW:
             if(!SS.OkayToStartNewFile()) break;
 
-            SS.saveFile = "";
+            SS.saveFile.Clear();
             SS.NewFile();
             SS.AfterNewFile();
             break;
@@ -443,9 +440,9 @@ void SolveSpaceUI::MenuFile(Command id) {
         case Command::OPEN: {
             if(!SS.OkayToStartNewFile()) break;
 
-            std::string newFile;
+            Platform::Path newFile;
             if(GetOpenFile(&newFile, "", SlvsFileFilter)) {
-                SS.OpenFile(newFile);
+                SS.Load(newFile);
             }
             break;
         }
@@ -459,22 +456,22 @@ void SolveSpaceUI::MenuFile(Command id) {
             break;
 
         case Command::EXPORT_PNG: {
-            std::string exportFile = SS.saveFile;
+            Platform::Path exportFile = SS.saveFile;
             if(!GetSaveFile(&exportFile, "", PngFileFilter)) break;
             SS.ExportAsPngTo(exportFile);
             break;
         }
 
         case Command::EXPORT_VIEW: {
-            std::string exportFile = SS.saveFile;
+            Platform::Path exportFile = SS.saveFile;
             if(!GetSaveFile(&exportFile, CnfThawString("", "ViewExportFormat"),
                             VectorFileFilter)) break;
-            CnfFreezeString(Extension(exportFile), "ViewExportFormat");
+            CnfFreezeString(exportFile.Extension(), "ViewExportFormat");
 
             // If the user is exporting something where it would be
             // inappropriate to include the constraints, then warn.
             if(SS.GW.showConstraints &&
-                (FilenameHasExtension(exportFile, ".txt") ||
+                (exportFile.HasExtension("txt") ||
                  fabs(SS.exportOffset) > LENGTH_EPS))
             {
                 Message(_("Constraints are currently shown, and will be exported "
@@ -488,40 +485,40 @@ void SolveSpaceUI::MenuFile(Command id) {
         }
 
         case Command::EXPORT_WIREFRAME: {
-            std::string exportFile = SS.saveFile;
+            Platform::Path exportFile = SS.saveFile;
             if(!GetSaveFile(&exportFile, CnfThawString("", "WireframeExportFormat"),
                             Vector3dFileFilter)) break;
-            CnfFreezeString(Extension(exportFile), "WireframeExportFormat");
+            CnfFreezeString(exportFile.Extension(), "WireframeExportFormat");
 
             SS.ExportViewOrWireframeTo(exportFile, /*exportWireframe*/true);
             break;
         }
 
         case Command::EXPORT_SECTION: {
-            std::string exportFile = SS.saveFile;
+            Platform::Path exportFile = SS.saveFile;
             if(!GetSaveFile(&exportFile, CnfThawString("", "SectionExportFormat"),
                             VectorFileFilter)) break;
-            CnfFreezeString(Extension(exportFile), "SectionExportFormat");
+            CnfFreezeString(exportFile.Extension(), "SectionExportFormat");
 
             SS.ExportSectionTo(exportFile);
             break;
         }
 
         case Command::EXPORT_MESH: {
-            std::string exportFile = SS.saveFile;
+            Platform::Path exportFile = SS.saveFile;
             if(!GetSaveFile(&exportFile, CnfThawString("", "MeshExportFormat"),
                             MeshFileFilter)) break;
-            CnfFreezeString(Extension(exportFile), "MeshExportFormat");
+            CnfFreezeString(exportFile.Extension(), "MeshExportFormat");
 
             SS.ExportMeshTo(exportFile);
             break;
         }
 
         case Command::EXPORT_SURFACES: {
-            std::string exportFile = SS.saveFile;
+            Platform::Path exportFile = SS.saveFile;
             if(!GetSaveFile(&exportFile, CnfThawString("", "SurfacesExportFormat"),
                             SurfaceFileFilter)) break;
-            CnfFreezeString(Extension(exportFile), "SurfacesExportFormat");
+            CnfFreezeString(exportFile.Extension(), "SurfacesExportFormat");
 
             StepFileWriter sfw = {};
             sfw.ExportSurfacesTo(exportFile);
@@ -529,18 +526,18 @@ void SolveSpaceUI::MenuFile(Command id) {
         }
 
         case Command::IMPORT: {
-            std::string importFile;
+            Platform::Path importFile;
             if(!GetOpenFile(&importFile, CnfThawString("", "ImportFormat"),
                             ImportableFileFilter)) break;
-            CnfFreezeString(Extension(importFile), "ImportFormat");
+            CnfFreezeString(importFile.Extension(), "ImportFormat");
 
-            if(Extension(importFile) == "dxf") {
+            if(importFile.HasExtension("dxf")) {
                 ImportDxf(importFile);
-            } else if(Extension(importFile) == "dwg") {
+            } else if(importFile.HasExtension("dwg")) {
                 ImportDwg(importFile);
             } else {
                 Error("Can't identify file type from file extension of "
-                      "filename '%s'; try .dxf or .dwg.", importFile.c_str());
+                      "filename '%s'; try .dxf or .dwg.", importFile.raw.c_str());
             }
 
             SS.GenerateAll(SolveSpaceUI::Generate::UNTIL_ACTIVE);
@@ -760,9 +757,9 @@ void SolveSpaceUI::MenuAnalyze(Command id) {
             break;
 
         case Command::STOP_TRACING: {
-            std::string exportFile = SS.saveFile;
+            Platform::Path exportFile = SS.saveFile;
             if(GetSaveFile(&exportFile, "", CsvFileFilter)) {
-                FILE *f = ssfopen(exportFile, "wb");
+                FILE *f = OpenFile(exportFile, "wb");
                 if(f) {
                     int i;
                     SContour *sc = &(SS.traced.path);
@@ -774,7 +771,7 @@ void SolveSpaceUI::MenuAnalyze(Command id) {
                     }
                     fclose(f);
                 } else {
-                    Error("Couldn't write to '%s'", exportFile.c_str());
+                    Error("Couldn't write to '%s'", exportFile.raw.c_str());
                 }
             }
             // Clear the trace, and stop tracing
diff --git a/src/solvespace.h b/src/solvespace.h
index 33d9381..7718191 100644
--- a/src/solvespace.h
+++ b/src/solvespace.h
@@ -121,13 +121,6 @@ inline double ffabs(double v) { return (v > 0) ? v : (-v); }
 #define VERY_POSITIVE   (1e10)
 #define VERY_NEGATIVE   (-1e10)
 
-#if defined(WIN32)
-std::string Narrow(const wchar_t *s);
-std::wstring Widen(const char *s);
-std::string Narrow(const std::wstring &s);
-std::wstring Widen(const std::string &s);
-#endif
-
 inline double Random(double vmax) {
     return (vmax*rand()) / RAND_MAX;
 }
@@ -144,30 +137,17 @@ enum class ContextCommand : uint32_t;
 
 #include "platform/platform.h"
 
-#if defined(WIN32)
-#define PATH_SEP "\\"
-#else
-#define PATH_SEP "/"
-#endif
-
-bool PathEqual(const std::string &a, const std::string &b);
-std::string PathSepPlatformToUnix(const std::string &filename);
-std::string PathSepUnixToPlatform(const std::string &filename);
-std::string PathFromCurrentDirectory(const std::string &relFilename);
-FILE *ssfopen(const std::string &filename, const char *mode);
-void ssremove(const std::string &filename);
-
 const size_t MAX_RECENT = 8;
-extern std::string RecentFile[MAX_RECENT];
+extern Platform::Path RecentFile[MAX_RECENT];
 void RefreshRecentMenus();
 
 enum DialogChoice { DIALOG_YES = 1, DIALOG_NO = -1, DIALOG_CANCEL = 0 };
 DialogChoice SaveFileYesNoCancel();
 DialogChoice LoadAutosaveYesNo();
-DialogChoice LocateImportedFileYesNoCancel(const std::string &filename,
+DialogChoice LocateImportedFileYesNoCancel(const Platform::Path &filename,
                                            bool canCancel);
 
-#define AUTOSAVE_SUFFIX "~"
+#define AUTOSAVE_EXT "slvs~"
 
 enum class Unit : uint32_t {
     MM = 0,
@@ -176,11 +156,11 @@ enum class Unit : uint32_t {
 
 struct FileFilter;
 
-bool GetSaveFile(std::string *filename, const std::string &defExtension,
+bool GetSaveFile(Platform::Path *filename, const std::string &defExtension,
                  const FileFilter filters[]);
-bool GetOpenFile(std::string *filename, const std::string &defExtension,
+bool GetOpenFile(Platform::Path *filename, const std::string &defExtension,
                  const FileFilter filters[]);
-std::vector<std::string> GetFontFiles();
+std::vector<Platform::Path> GetFontFiles();
 
 void OpenWebsite(const char *url);
 
@@ -219,7 +199,7 @@ void dbp(const char *str, ...);
     dbp("tri: (%.3f %.3f %.3f) (%.3f %.3f %.3f) (%.3f %.3f %.3f)", \
         CO((tri).a), CO((tri).b), CO((tri).c))
 
-void SetCurrentFilename(const std::string &filename);
+void SetCurrentFilename(const Platform::Path &filename);
 void SetMousePointerToHand(bool yes);
 void DoMessageBox(const char *str, int rows, int cols, bool error);
 void SetTimerFor(int milliseconds);
@@ -314,12 +294,6 @@ void MakeMatrix(double *mat, double a11, double a12, double a13, double a14,
 void MultMatrix(double *mata, double *matb, double *matr);
 
 std::string MakeAcceleratorLabel(int accel);
-bool FilenameHasExtension(const std::string &str, const char *ext);
-std::string Extension(const std::string &filename);
-std::string Basename(std::string filename, bool stripExtension = false);
-std::string Dirname(std::string filename);
-bool ReadFile(const std::string &filename, std::string *data);
-bool WriteFile(const std::string &filename, const std::string &data);
 void Message(const char *str, ...);
 void Error(const char *str, ...);
 void CnfFreezeBool(bool v, const std::string &name);
@@ -411,7 +385,7 @@ public:
 
 class StepFileWriter {
 public:
-    void ExportSurfacesTo(const std::string &filename);
+    void ExportSurfacesTo(const Platform::Path &filename);
     void WriteHeader();
 	void WriteProductHeader();
     int ExportCurve(SBezier *sb);
@@ -433,12 +407,12 @@ protected:
 
 public:
     FILE *f;
-    std::string filename;
+    Platform::Path filename;
     Vector ptMin, ptMax;
 
     static double MmToPts(double mm);
 
-    static VectorFileWriter *ForFile(const std::string &filename);
+    static VectorFileWriter *ForFile(const Platform::Path &filename);
 
     void SetModelviewProjection(const Vector &u, const Vector &v, const Vector &n,
                                 const Vector &origin, double cameraTan, double scale);
@@ -674,7 +648,7 @@ public:
     bool     drawBackFaces;
     bool     checkClosedContour;
     bool     showToolbar;
-    std::string screenshotFile;
+    Platform::Path screenshotFile;
     RgbaColor backgroundColor;
     bool     exportShadedTriangles;
     bool     exportPwlCurves;
@@ -725,16 +699,16 @@ public:
 
     // The platform-dependent code calls this before entering the msg loop
     void Init();
-    bool OpenFile(const std::string &filename);
+    bool Load(const Platform::Path &filename);
     void Exit();
 
     // File load/save routines, including the additional files that get
     // loaded when we have link groups.
     FILE        *fh;
     void AfterNewFile();
-    static void RemoveFromRecentList(const std::string &filename);
-    static void AddToRecentList(const std::string &filename);
-    std::string saveFile;
+    static void RemoveFromRecentList(const Platform::Path &filename);
+    static void AddToRecentList(const Platform::Path &filename);
+    Platform::Path saveFile;
     bool        fileLoadError;
     bool        unsaved;
     typedef struct {
@@ -744,8 +718,8 @@ public:
         void       *ptr;
     } SaveTable;
     static const SaveTable SAVED[];
-    void SaveUsingTable(int type);
-    void LoadUsingTable(char *key, char *val);
+    void SaveUsingTable(const Platform::Path &filename, int type);
+    void LoadUsingTable(const Platform::Path &filename, char *key, char *val);
     struct {
         Group        g;
         Request      r;
@@ -763,22 +737,22 @@ public:
     void UpdateWindowTitle();
     void ClearExisting();
     void NewFile();
-    bool SaveToFile(const std::string &filename);
-    bool LoadAutosaveFor(const std::string &filename);
-    bool LoadFromFile(const std::string &filename, bool canCancel = false);
+    bool SaveToFile(const Platform::Path &filename);
+    bool LoadAutosaveFor(const Platform::Path &filename);
+    bool LoadFromFile(const Platform::Path &filename, bool canCancel = false);
     void UpgradeLegacyData();
-    bool LoadEntitiesFromFile(const std::string &filename, EntityList *le,
+    bool LoadEntitiesFromFile(const Platform::Path &filename, EntityList *le,
                               SMesh *m, SShell *sh);
-    bool ReloadAllImported(const std::string &filename = "", bool canCancel = false);
+    bool ReloadAllImported(const Platform::Path &filename, bool canCancel = false);
     // And the various export options
-    void ExportAsPngTo(const std::string &filename);
-    void ExportMeshTo(const std::string &filename);
+    void ExportAsPngTo(const Platform::Path &filename);
+    void ExportMeshTo(const Platform::Path &filename);
     void ExportMeshAsStlTo(FILE *f, SMesh *sm);
     void ExportMeshAsObjTo(FILE *fObj, FILE *fMtl, SMesh *sm);
-    void ExportMeshAsThreeJsTo(FILE *f, const std::string &filename,
+    void ExportMeshAsThreeJsTo(FILE *f, const Platform::Path &filename,
                                SMesh *sm, SOutlineList *sol);
-    void ExportViewOrWireframeTo(const std::string &filename, bool exportWireframe);
-    void ExportSectionTo(const std::string &filename);
+    void ExportViewOrWireframeTo(const Platform::Path &filename, bool exportWireframe);
+    void ExportSectionTo(const Platform::Path &filename);
     void ExportWireframeCurves(SEdgeList *sel, SBezierList *sbl,
                                VectorFileWriter *out);
     void ExportLinesAndMesh(SEdgeList *sel, SBezierList *sbl, SMesh *sm,
@@ -903,8 +877,8 @@ public:
     }
 };
 
-void ImportDxf(const std::string &file);
-void ImportDwg(const std::string &file);
+void ImportDxf(const Platform::Path &file);
+void ImportDwg(const Platform::Path &file);
 
 extern SolveSpaceUI SS;
 extern Sketch SK;
diff --git a/src/style.cpp b/src/style.cpp
index 4af11c8..eb30516 100644
--- a/src/style.cpp
+++ b/src/style.cpp
@@ -393,16 +393,16 @@ void TextWindow::ScreenBackgroundImage(int link, uint32_t v) {
     SS.bgImage.pixmap = nullptr;
 
     if(link == 'l') {
-        std::string bgImageFile;
+        Platform::Path bgImageFile;
         if(GetOpenFile(&bgImageFile, "", PngFileFilter)) {
-            FILE *f = ssfopen(bgImageFile, "rb");
+            FILE *f = OpenFile(bgImageFile, "rb");
             if(f) {
                 SS.bgImage.pixmap = Pixmap::ReadPng(f);
                 SS.bgImage.scale  = SS.GW.scale;
                 SS.bgImage.origin = SS.GW.offset.ScaledBy(-1);
                 fclose(f);
             } else {
-                Error("Error reading PNG file '%s'", bgImageFile.c_str());
+                Error("Error reading PNG file '%s'", bgImageFile.raw.c_str());
             }
         }
     }
diff --git a/src/textscreens.cpp b/src/textscreens.cpp
index 8b3eb48..49ba0ac 100644
--- a/src/textscreens.cpp
+++ b/src/textscreens.cpp
@@ -327,7 +327,12 @@ void TextWindow::ShowGroupInfo() {
         }
     } else if(g->type == Group::Type::LINKED) {
         Printf(true, " %Ftlink geometry from file%E");
-        Printf(false, "%Ba   '%s'", g->linkFileRel.c_str());
+        Platform::Path relativePath =g->linkFile.RelativeTo(SS.saveFile.Parent());
+        if(relativePath.IsEmpty()) {
+            Printf(false, "%Ba   '%s'", g->linkFile.raw.c_str());
+        } else {
+            Printf(false, "%Ba   '%s'", relativePath.raw.c_str());
+        }
         Printf(false, "%Bd   %Ftscaled by%E %# %Fl%Ll%f%D[change]%E",
             g->scale,
             &TextWindow::ScreenChangeGroupScale, g->h.v);
diff --git a/src/ttf.cpp b/src/ttf.cpp
index 5d64467..8251a1d 100644
--- a/src/ttf.cpp
+++ b/src/ttf.cpp
@@ -56,7 +56,7 @@ TtfFontList::~TtfFontList() {
 void TtfFontList::LoadAll() {
     if(loaded) return;
 
-    for(const std::string &font : GetFontFiles()) {
+    for(const Platform::Path &font : GetFontFiles()) {
         TtfFont tf = {};
         tf.fontFile = font;
         if(tf.LoadFromFile(fontLibrary))
@@ -127,7 +127,7 @@ double TtfFontList::AspectRatio(const std::string &font, const std::string &str)
 // entities that reference us will store it.
 //-----------------------------------------------------------------------------
 std::string TtfFont::FontFileBaseName() const {
-    return Basename(fontFile);
+    return fontFile.FileName();
 }
 
 //-----------------------------------------------------------------------------
@@ -138,20 +138,20 @@ std::string TtfFont::FontFileBaseName() const {
 bool TtfFont::LoadFromFile(FT_Library fontLibrary, bool nameOnly) {
     FT_Open_Args args = {};
     args.flags    = FT_OPEN_PATHNAME;
-    args.pathname = &fontFile[0]; // FT_String is char* for historical reasons
+    args.pathname = &fontFile.raw[0]; // FT_String is char* for historical reasons
 
-    // We don't use ssfopen() here to let freetype do its own memory management.
+    // We don't use OpenFile() here to let freetype do its own memory management.
     // This is OK because on Linux/OS X we just delegate to fopen and on Windows
     // we only look into C:\Windows\Fonts, which has a known short path.
     if(int fterr = FT_Open_Face(fontLibrary, &args, 0, &fontFace)) {
         dbp("freetype: loading font from file '%s' failed: %s",
-            fontFile.c_str(), ft_error_string(fterr));
+            fontFile.raw.c_str(), ft_error_string(fterr));
         return false;
     }
 
     if(int fterr = FT_Select_Charmap(fontFace, FT_ENCODING_UNICODE)) {
         dbp("freetype: loading unicode CMap for file '%s' failed: %s",
-            fontFile.c_str(), ft_error_string(fterr));
+            fontFile.raw.c_str(), ft_error_string(fterr));
         FT_Done_Face(fontFace);
         fontFace = NULL;
         return false;
diff --git a/src/ttf.h b/src/ttf.h
index ff016c3..fe82fee 100644
--- a/src/ttf.h
+++ b/src/ttf.h
@@ -11,7 +11,7 @@
 
 class TtfFont {
 public:
-    std::string     fontFile;
+    Platform::Path  fontFile;
     std::string     name;
     FT_FaceRec_    *fontFace;
     double          capHeight;
diff --git a/src/undoredo.cpp b/src/undoredo.cpp
index 92fbb98..be1fef2 100644
--- a/src/undoredo.cpp
+++ b/src/undoredo.cpp
@@ -138,7 +138,7 @@ void SolveSpaceUI::PopOntoCurrentFrom(UndoStack *uk) {
     // sketch just changed a lot.
     SS.GW.ClearSuper();
     SS.TW.ClearSuper();
-    SS.ReloadAllImported();
+    SS.ReloadAllImported(SS.saveFile);
     SS.GenerateAll(SolveSpaceUI::Generate::ALL);
     SS.ScheduleShowTW();
 
diff --git a/src/util.cpp b/src/util.cpp
index b1d179a..ecf0eb0 100644
--- a/src/util.cpp
+++ b/src/util.cpp
@@ -48,83 +48,6 @@ char32_t utf8_iterator::operator*()
     return result;
 }
 
-bool SolveSpace::FilenameHasExtension(const std::string &str, const char *ext)
-{
-    int i, ls = str.length(), le = strlen(ext);
-
-    if(ls < le) return false;
-
-    for(i = 0; i < le; i++) {
-        if(tolower(ext[le-i-1]) != tolower(str[ls-i-1])) {
-            return false;
-        }
-    }
-    return true;
-}
-
-std::string SolveSpace::Extension(const std::string &filename) {
-    int dot = filename.rfind('.');
-    if(dot >= 0) {
-        std::string ext = filename.substr(dot + 1, filename.length());
-        std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
-        return ext;
-    }
-
-    return "";
-}
-
-std::string SolveSpace::Basename(std::string filename, bool stripExtension) {
-    int slash = filename.rfind(PATH_SEP);
-    if(slash >= 0) {
-        filename = filename.substr(slash + 1, filename.length());
-    }
-
-    if(stripExtension) {
-        int dot = filename.rfind('.');
-        if(dot >= 0) {
-            filename = filename.substr(0, dot);
-        }
-    }
-
-    return filename;
-}
-
-std::string SolveSpace::Dirname(std::string filename) {
-    int slash = filename.rfind(PATH_SEP);
-    if(slash >= 0) {
-        return filename.substr(0, slash);
-    }
-
-    return "";
-}
-
-bool SolveSpace::ReadFile(const std::string &filename, std::string *data)
-{
-    FILE *f = ssfopen(filename.c_str(), "rb");
-    if(f == NULL)
-        return false;
-
-    fseek(f, 0, SEEK_END);
-    data->resize(ftell(f));
-    fseek(f, 0, SEEK_SET);
-    fread(&(*data)[0], 1, data->size(), f);
-    fclose(f);
-
-    return true;
-}
-
-bool SolveSpace::WriteFile(const std::string &filename, const std::string &data)
-{
-    FILE *f = ssfopen(filename.c_str(), "wb");
-    if(f == NULL)
-        return false;
-
-    fwrite(&data[0], 1, data.size(), f);
-    fclose(f);
-
-    return true;
-}
-
 int64_t SolveSpace::GetMilliseconds()
 {
     auto timestamp = std::chrono::steady_clock::now().time_since_epoch();
diff --git a/test/harness.cpp b/test/harness.cpp
index 79465c7..70f340c 100644
--- a/test/harness.cpp
+++ b/test/harness.cpp
@@ -16,7 +16,7 @@
 
 namespace SolveSpace {
     // These are defined in headless.cpp, and aren't exposed in solvespace.h.
-    extern std::vector<std::string> fontFiles;
+    extern std::vector<Platform::Path> fontFiles;
     extern bool antialias;
     extern std::shared_ptr<Pixmap> framebuffer;
 }
@@ -24,7 +24,6 @@ namespace SolveSpace {
 // The paths in __FILE__ are from the build system, but defined(WIN32) returns
 // the value for the host system.
 #define BUILD_PATH_SEP (__FILE__[0]=='/' ? '/' : '\\')
-#define  HOST_PATH_SEP PATH_SEP
 
 static std::string BuildRoot() {
     static std::string rootDir;
@@ -35,40 +34,24 @@ static std::string BuildRoot() {
     return rootDir;
 }
 
-static std::string HostRoot() {
-    static std::string rootDir;
-    if(!rootDir.empty()) return rootDir;
+static Platform::Path HostRoot() {
+    static Platform::Path rootDir;
+    if(!rootDir.IsEmpty()) return rootDir;
 
     // No especially good way to do this, so let's assume somewhere up from
     // the current directory there's our repository, with CMakeLists.txt, and
     // pivot from there.
-#if defined(WIN32)
-    wchar_t currentDirW[MAX_PATH];
-    GetCurrentDirectoryW(MAX_PATH, currentDirW);
-    rootDir = Narrow(currentDirW);
-#else
-    rootDir = ".";
-#endif
+    rootDir = Platform::Path::CurrentDirectory();
 
     // We're never more than four levels deep.
     for(size_t i = 0; i < 4; i++) {
-        std::string listsPath = rootDir;
-        listsPath += HOST_PATH_SEP;
-        listsPath += "CMakeLists.txt";
-        FILE *f = ssfopen(listsPath, "r");
+        FILE *f = OpenFile(rootDir.Join("CMakeLists.txt"), "r");
         if(f) {
             fclose(f);
-            rootDir += HOST_PATH_SEP;
-            rootDir += "test";
+            rootDir = rootDir.Join("test");
             return rootDir;
         }
-
-        if(rootDir[0] == '.') {
-            rootDir += HOST_PATH_SEP;
-            rootDir += "..";
-        } else {
-            rootDir.erase(rootDir.rfind(HOST_PATH_SEP));
-        }
+        rootDir = rootDir.Parent();
     }
 
     ssassert(false, "Couldn't locate repository root");
@@ -164,14 +147,14 @@ void Test::Helper::PrintFailure(const char *file, int line, std::string msg) {
             BUILD_PATH_SEP, shortFile.c_str(), line, msg.c_str());
 }
 
-std::string Test::Helper::GetAssetPath(std::string testFile, std::string assetName,
-                                       std::string mangle) {
+Platform::Path Test::Helper::GetAssetPath(std::string testFile, std::string assetName,
+                                          std::string mangle) {
     if(!mangle.empty()) {
         assetName.insert(assetName.rfind('.'), "." + mangle);
     }
     testFile.erase(0, BuildRoot().size());
     testFile.erase(testFile.rfind(BUILD_PATH_SEP) + 1);
-    return PathSepUnixToPlatform(HostRoot() + "/" + testFile + assetName);
+    return HostRoot().Join(Platform::Path::FromPortable(testFile + assetName));
 }
 
 bool Test::Helper::CheckBool(const char *file, int line, const char *expr, bool value,
@@ -213,16 +196,16 @@ bool Test::Helper::CheckEqualEpsilon(const char *file, int line, const char *val
 }
 
 bool Test::Helper::CheckLoad(const char *file, int line, const char *fixture) {
-    std::string fixturePath = GetAssetPath(file, fixture);
+    Platform::Path fixturePath = GetAssetPath(file, fixture);
 
-    FILE *f = ssfopen(fixturePath.c_str(), "rb");
+    FILE *f = OpenFile(fixturePath, "rb");
     bool fixtureExists = (f != NULL);
     if(f) fclose(f);
 
     bool result = fixtureExists && SS.LoadFromFile(fixturePath);
     if(!RecordCheck(result)) {
         PrintFailure(file, line,
-                     ssprintf("loading file '%s'", fixturePath.c_str()));
+                     ssprintf("loading file '%s'", fixturePath.raw.c_str()));
         return false;
     } else {
         SS.AfterNewFile();
@@ -233,11 +216,11 @@ bool Test::Helper::CheckLoad(const char *file, int line, const char *fixture) {
 }
 
 bool Test::Helper::CheckSave(const char *file, int line, const char *reference) {
-    std::string refPath = GetAssetPath(file, reference),
-                outPath = GetAssetPath(file, reference, "out");
+    Platform::Path refPath = GetAssetPath(file, reference),
+                   outPath = GetAssetPath(file, reference, "out");
     if(!RecordCheck(SS.SaveToFile(outPath))) {
         PrintFailure(file, line,
-                     ssprintf("saving file '%s'", refPath.c_str()));
+                     ssprintf("saving file '%s'", refPath.raw.c_str()));
         return false;
     } else {
         std::string refData, outData;
@@ -248,7 +231,7 @@ bool Test::Helper::CheckSave(const char *file, int line, const char *reference)
             return false;
         }
 
-        ssremove(outPath);
+        RemoveFile(outPath);
         return true;
     }
 }
@@ -256,11 +239,11 @@ bool Test::Helper::CheckSave(const char *file, int line, const char *reference)
 bool Test::Helper::CheckRender(const char *file, int line, const char *reference) {
     PaintGraphics();
 
-    std::string refPath  = GetAssetPath(file, reference),
-                outPath  = GetAssetPath(file, reference, "out"),
-                diffPath = GetAssetPath(file, reference, "diff");
+    Platform::Path refPath  = GetAssetPath(file, reference),
+                   outPath  = GetAssetPath(file, reference, "out"),
+                   diffPath = GetAssetPath(file, reference, "diff");
 
-    std::shared_ptr<Pixmap> refPixmap = Pixmap::ReadPng(refPath.c_str(), /*flip=*/true);
+    std::shared_ptr<Pixmap> refPixmap = Pixmap::ReadPng(refPath, /*flip=*/true);
     if(!RecordCheck(refPixmap && refPixmap->Equals(*framebuffer))) {
         framebuffer->WritePng(outPath, /*flip=*/true);
 
@@ -287,7 +270,7 @@ bool Test::Helper::CheckRender(const char *file, int line, const char *reference
                 }
             }
 
-            diffPixmap->WritePng(diffPath.c_str(), /*flip=*/true);
+            diffPixmap->WritePng(diffPath, /*flip=*/true);
             std::string message =
                 ssprintf("render doesn't match reference; %d (%.2f%%) pixels differ",
                          diffPixelCount,
@@ -296,8 +279,8 @@ bool Test::Helper::CheckRender(const char *file, int line, const char *reference
         }
         return false;
     } else {
-        ssremove(outPath);
-        ssremove(diffPath);
+        RemoveFile(outPath);
+        RemoveFile(diffPath);
         return true;
     }
 }
@@ -336,7 +319,7 @@ int main(int argc, char **argv) {
         return 1;
     }
 
-    fontFiles.push_back(HostRoot() + HOST_PATH_SEP + "Gentium-R.ttf");
+    fontFiles.push_back(HostRoot().Join("Gentium-R.ttf"));
 
     // Different Cairo versions have different antialiasing algorithms.
     antialias = false;
diff --git a/test/harness.h b/test/harness.h
index e129dbc..fa95900 100644
--- a/test/harness.h
+++ b/test/harness.h
@@ -19,8 +19,8 @@ public:
 
     bool RecordCheck(bool success);
     void PrintFailure(const char *file, int line, std::string msg);
-    std::string GetAssetPath(std::string testFile, std::string assetName,
-                             std::string mangle = "");
+    Platform::Path GetAssetPath(std::string testFile, std::string assetName,
+                                std::string mangle = "");
 
     bool CheckBool(const char *file, int line, const char *expr,
                    bool value, bool reference);