-`fstl` is a viewer for [.stl files](http://en.wikipedia.org/wiki/STL_\(file_format\)).
+# fstl
-Here's a screenshot:
-![Eiffel tower](http://mattkeeter.com/projects/fstl/eiffel.png)
-(credit to [Pranav Panchal](https://grabcad.com/pranav.panchal))
+`fstl` is the fastest [.stl file](http://en.wikipedia.org/wiki/STL_\(file_format\)) viewer.
It is designed to quickly load and render very high-polygon models;
showing 2 million triangles at 60+ FPS on a mid-range laptop.
For more details, see the [project page](http://mattkeeter.com/projects/fstl).
-Issues and pull requests are welcome;
+Issues and minor pull requests are welcome;
the project is under 1K lines of code and should be fairly approachable.
---------------------------------------------------------------------------------
+## Screenshot
+![Eiffel tower](http://mattkeeter.com/projects/fstl/eiffel.png)
+(credit to [Pranav Panchal](https://grabcad.com/pranav.panchal))
+
+## Building
+
+The only dependency for `fstl` is [Qt](https://www.qt.io).
+
+### macOS
-License
--------
-(c) 2014 Matt Keeter
+Install Qt from their website or [Homebrew](brew.sh),
+making sure `qmake` is on your shell's path.
-This work may be reproduced, modified, distributed, performed, and displayed for any purpose. Copyright is retained and must be preserved. The work is provided as is; no warranty is provided, and users accept all liability.
+Then, run through the following set of commands in a shell:
+```
+git clone https://github.com/mkeeter/fstl
+cd fstl
+mkdir build
+cd build
+qmake ../qt/fstl.pro
+make -j8
+./fstl.app/Contents/MacOS/fstl
+```
+
+To package a standalone app, go to the app directory and run `package.sh`
+
+```
+cd ../app
+./package.sh
+```
+
+This should produce two new files in the root directory:
+- `fstl.app` is a standalone application that can be copied to `/Applications`
+- `fstl.dmg` is a disk image that can be given to a friend
--------------------------------------------------------------------------------
-*p.s.* In my day job, I'm an engineer at [Formlabs](http://formlabs.com).
-If you think `fstl` is cool, you should check out our
-[jobs page](http://formlabs.com/jobs) -- we're hiring software developers
-and engineers of all stripes to work on interesting, hard problems in
-consumer-level 3D printing.
+# License
+
+Copyright (c) 2014-2017 Matthew Keeter
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#!/bin/sh
+set -x -e
+
cd ../build
-macdeployqt fstl.app
+make clean
+rm -rf fstl.app
+make -j8
+
+APP=fstl
+
+# Pull out framework paths info with otool
+MACDEPLOYQT=`otool -L $APP.app/Contents/MacOS/fstl | sed -n -e "s:\(.*\)lib/QtCore.*:\1/bin/macdeployqt:gp"`
+
+$MACDEPLOYQT $APP.app
+
+# Delete unused Qt plugins
cd fstl.app/Contents/PlugIns
rm -rf accessible audio imageformats mediaservice playlistformats position printsupport qml1tooling sensorgestures sensors
-cd ../Frameworks
-rm -rf QtDeclarative.framework QtMultimedia.framework QtMultimediaWidgets.framework QtNetwork.framework QtPositioning.framework QtQml.framework QtQuick.framework QtScript.framework QtSensors.framework QtSql.framework QtXmlPatterns.framework
+
+fix_qt () {
+ echo "Fixing Qt for $1"
+ for LIB in $( otool -L $1 | sed -n -e "s:\(.*Qt.*.framework[^ ]*\).*:\1:gp" )
+ do
+ RENAMED=`echo $LIB | sed -n -e "s:.*\(Qt.*\)\.framework.*:@executable_path/../Frameworks/\1.framework/Versions/5/\1:gp"`
+ install_name_tool -change $LIB $RENAMED $1
+ done
+}
+
+# Remap platform links
+cd platforms
+fix_qt libqcocoa.dylib
+
+# Delete unused Qt frameworks
+cd ../../Frameworks
+rm -rf QtDeclarative.framework QtMultimedia.framework QtMultimediaWidgets.framework QtNetwork.framework QtPositioning.framework QtQml.framework QtQuick.framework QtScript.framework QtSensors.framework QtSql.framework QtXmlPatterns.framework Qt3DCore.framework Qt3DRender.framework QtLocation.framework QtSerialBus.framework QtSerialPort.framework
+
+# Clean up remaining Qt frameworks
+for LIB in $( ls|sed -n -e "s:\(Qt.*\)\.framework:\1:gp" )
+do
+ fix_qt $LIB.framework/Versions/Current/$LIB
+done
+
cd ../Resources
rm empty.lproj
-cd ../../..
-cp -r fstl.app ..
-cd ..
-zip -r fstl_mac.zip fstl.app README.md
+# Create a disk image
+cd ../../..
+mkdir $APP
+cp ../README.md ./$APP/README.txt
+cp -R $APP.app ./$APP
+hdiutil create $APP.dmg -volname "$APP" -srcfolder $APP
+rm -rf $APP
+mv $APP.dmg ..
-cd ..
-cp build/release/fstl.exe .
-/c/Program\ Files/7-Zip/7z.exe a fstl_win.zip fstl.exe README.md
+#!/bin/sh
+set -e -x
+
+cd ../build
+make clean
+rm -rf fstl.exe
+make -j8
+
+cp ../README.md README.txt
+cp release/fstl.exe .
+/c/Program\ Files/7-Zip/7z.exe a fstl.zip fstl.exe README.txt
+cp fstl.zip ..
+rm README.txt fstl.exe
RC_FILE = ../exe/fstl.rc
}
+linux {
+ target.path = /usr/bin
+ INSTALLS += target
+}
+
static {
CONFIG += static
}
QWidget {
- background-color: #fdf6e3;
+ background-color: null;
color: #839496;
}
-
-QPushButton {
- background-color: #eee8d5;
- border-top-width: 5px;
- border-bottom-width: 5px;
- border-left-width: 30px;
- border-right-width: 30px;
- border-style: flat;
- margin: 0px;
- color: #839496;
-}
-
-QPushButton:pressed {
- background-color: #ddd7c4;
-}
#include "app.h"
#include "window.h"
-App::App(int argc, char *argv[]) :
+App::App(int& argc, char *argv[]) :
QApplication(argc, argv), window(new Window())
{
window->show();
{
Q_OBJECT
public:
- explicit App(int argc, char *argv[]);
+ explicit App(int& argc, char *argv[]);
protected:
bool event(QEvent* e);
private:
Canvas::Canvas(const QGLFormat& format, QWidget *parent)
: QGLWidget(format, parent), mesh(NULL),
- scale(1), zoom(1), tilt(90), yaw(0), status(" ")
+ scale(1), zoom(1), tilt(90), yaw(0),
+ perspective(0.25), anim(this, "perspective"), status(" ")
{
- // Nothing to do here
+ QFile styleFile(":/qt/style.qss");
+ styleFile.open( QFile::ReadOnly );
+ setStyleSheet(styleFile.readAll());
+
+ anim.setDuration(100);
}
Canvas::~Canvas()
delete mesh;
}
-void Canvas::load_mesh(Mesh* m)
+void Canvas::view_anim(float v)
+{
+ anim.setStartValue(perspective);
+ anim.setEndValue(v);
+ anim.start();
+}
+
+void Canvas::view_orthographic()
+{
+ view_anim(0);
+}
+
+void Canvas::view_perspective()
+{
+ view_anim(0.25);
+}
+
+void Canvas::load_mesh(Mesh* m, bool is_reload)
{
mesh = new GLMesh(m);
- center = QVector3D(m->xmin() + m->xmax(),
- m->ymin() + m->ymax(),
- m->zmin() + m->zmax()) / 2;
- scale = 2 / sqrt(
- pow(m->xmax() - m->xmin(), 2) +
- pow(m->ymax() - m->ymin(), 2) +
- pow(m->zmax() - m->zmin(), 2));
-
- // Reset other camera parameters
- zoom = 1;
- yaw = 0;
- tilt = 90;
+
+ if (!is_reload)
+ {
+ QVector3D lower(m->xmin(), m->ymin(), m->zmin());
+ QVector3D upper(m->xmax(), m->ymax(), m->zmax());
+ center = (lower + upper) / 2;
+ scale = 2 / (upper - lower).length();
+
+ // Reset other camera parameters
+ zoom = 1;
+ yaw = 0;
+ tilt = 90;
+ }
update();
update();
}
+void Canvas::set_perspective(float p)
+{
+ perspective = p;
+ update();
+}
+
void Canvas::clear_status()
{
status = "";
QMatrix4x4 m;
m.rotate(tilt, QVector3D(1, 0, 0));
m.rotate(yaw, QVector3D(0, 0, 1));
- m.scale(scale);
+ m.scale(-scale, scale, -scale);
m.translate(-center);
return m;
}
m.scale(-1, width() / float(height()), 0.5);
}
m.scale(zoom, zoom, 1);
+ m(3, 2) = perspective;
return m;
}
auto p = event->pos();
auto d = p - mouse_pos;
+
if (event->buttons() & Qt::LeftButton)
{
yaw = fmod(yaw - d.x(), 360);
- tilt = fmax(0, fmin(180, tilt - d.y()));
+ tilt = fmod(tilt - d.y(), 360);
update();
}
else if (event->buttons() & Qt::RightButton)
center += b - a;
update();
}
+
+void Canvas::resizeGL(int width, int height)
+{
+ glViewport(0, 0, width, height);
+}
#define CANVAS_H
#include <QWidget>
+#include <QPropertyAnimation>
#include <QtOpenGL/QGLWidget>
#include <QtOpenGL/QGLFunctions>
#include <QtOpenGL/QGLShaderProgram>
void paintEvent(QPaintEvent* event);
~Canvas();
+ void view_orthographic();
+ void view_perspective();
+
public slots:
void set_status(const QString& s);
void clear_status();
- void load_mesh(Mesh* m);
+ void load_mesh(Mesh* m, bool is_reload);
protected:
void mouseReleaseEvent(QMouseEvent* event);
void mouseMoveEvent(QMouseEvent* event);
void wheelEvent(QWheelEvent* event);
+ void resizeGL(int width, int height);
+ void set_perspective(float p);
+ void view_anim(float v);
private:
float tilt;
float yaw;
+ float perspective;
+ Q_PROPERTY(float perspective WRITE set_perspective);
+ QPropertyAnimation anim;
+
QPoint mouse_pos;
QString status;
};
+#include <future>
+
#include "loader.h"
-Loader::Loader(QObject* parent, const QString& filename)
- : QThread(parent), filename(filename)
+Loader::Loader(QObject* parent, const QString& filename, bool is_reload)
+ : QThread(parent), filename(filename), is_reload(is_reload)
{
// Nothing to do here
}
Mesh* mesh = load_stl();
if (mesh)
{
- emit got_mesh(mesh);
- emit loaded_file(filename);
+ if (mesh->empty())
+ {
+ emit error_empty_mesh();
+ delete mesh;
+ }
+ else
+ {
+ emit got_mesh(mesh, is_reload);
+ emit loaded_file(filename);
+ }
}
}
-
////////////////////////////////////////////////////////////////////////////////
struct Vec3
typedef std::pair<Vec3, GLuint> Vec3i;
+void parallel_sort(Vec3i* begin, Vec3i* end, int threads)
+{
+ if (threads < 2 || end - begin < 2)
+ {
+ std::sort(begin, end);
+ }
+ else
+ {
+ const auto mid = begin + (end - begin) / 2;
+ if (threads == 2)
+ {
+ auto future = std::async(parallel_sort, begin, mid, threads / 2);
+ std::sort(mid, end);
+ future.wait();
+ }
+ else
+ {
+ auto a = std::async(std::launch::async, parallel_sort, begin, mid, threads / 2);
+ auto b = std::async(std::launch::async, parallel_sort, mid, end, threads / 2);
+ a.wait();
+ b.wait();
+ }
+ std::inplace_merge(begin, mid, end);
+ }
+}
+
+Mesh* mesh_from_verts(uint32_t tri_count, QVector<Vec3i>& verts)
+{
+ // Save indicies as the second element in the array
+ // (so that we can reconstruct triangle order after sorting)
+ for (size_t i=0; i < tri_count*3; ++i)
+ {
+ verts[i].second = i;
+ }
+
+ // Sort the set of vertices (to deduplicate)
+ parallel_sort(verts.begin(), verts.end(), 8);
+
+ // This vector will store triangles as sets of 3 indices
+ std::vector<GLuint> indices(tri_count*3);
+
+ // Go through the sorted vertex list, deduplicating and creating
+ // an indexed geometry representation for the triangles.
+ // Unique vertices are moved so that they occupy the first vertex_count
+ // positions in the verts array.
+ size_t vertex_count = 0;
+ for (auto v : verts)
+ {
+ if (!vertex_count || v.first != verts[vertex_count-1].first)
+ {
+ verts[vertex_count++] = v;
+ }
+ indices[v.second] = vertex_count - 1;
+ }
+ verts.resize(vertex_count);
+
+ std::vector<GLfloat> flat_verts;
+ flat_verts.reserve(vertex_count*3);
+ for (auto v : verts)
+ {
+ flat_verts.push_back(v.first.x);
+ flat_verts.push_back(v.first.y);
+ flat_verts.push_back(v.first.z);
+ }
+
+ return new Mesh(flat_verts, indices);
+}
+
////////////////////////////////////////////////////////////////////////////////
Mesh* Loader::load_stl()
{
QFile file(filename);
- file.open(QIODevice::ReadOnly);
- if (file.read(5) == "solid")
+ if (!file.open(QIODevice::ReadOnly))
{
- emit error_ascii_stl();
+ emit error_missing_file();
return NULL;
}
- // Skip the rest of the header material
- file.read(75);
+ // First, try to read the stl as an ASCII file
+ if (file.read(6) == "solid ")
+ {
+ file.readLine(); // skip solid name
+ const auto line = file.readLine().trimmed();
+ if (line.startsWith("facet") ||
+ line.startsWith("endsolid"))
+ {
+ file.seek(0);
+ return read_stl_ascii(file);
+ }
+ confusing_stl = true;
+ }
+ else
+ {
+ confusing_stl = false;
+ }
+
+ // Otherwise, skip the rest of the header material and read as binary
+ file.seek(0);
+ return read_stl_binary(file);
+}
+
+Mesh* Loader::read_stl_binary(QFile& file)
+{
QDataStream data(&file);
data.setByteOrder(QDataStream::LittleEndian);
data.setFloatingPointPrecision(QDataStream::SinglePrecision);
// Load the triangle count from the .stl file
+ file.seek(80);
uint32_t tri_count;
data >> tri_count;
QVector<Vec3i> verts(tri_count*3);
// Dummy array, because readRawData is faster than skipRawData
- char buffer[sizeof(float)*3];
+ uint8_t* buffer = (uint8_t*)malloc(tri_count * 50);
+ data.readRawData((char*)buffer, tri_count * 50);
// Store vertices in the array, processing one triangle at a time.
+ auto b = buffer;
for (auto v=verts.begin(); v != verts.end(); v += 3)
{
// Skip face's normal vector
- data.readRawData(buffer, 3*sizeof(float));
+ b += 3 * sizeof(float);
// Load vertex data from .stl file into vertices
- data >> v[0].first.x >> v[0].first.y >> v[0].first.z;
- data >> v[1].first.x >> v[1].first.y >> v[1].first.z;
- data >> v[2].first.x >> v[2].first.y >> v[2].first.z;
+ for (unsigned i=0; i < 3; ++i)
+ {
+ memcpy(&v[i].first, b, 3*sizeof(float));
+ b += 3 * sizeof(float);
+ }
// Skip face attribute
- data.readRawData(buffer, sizeof(uint16_t));
+ b += sizeof(uint16_t);
}
- // Save indicies as the second element in the array
- // (so that we can reconstruct triangle order after sorting)
- for (size_t i=0; i < tri_count*3; ++i)
+ if (confusing_stl)
{
- verts[i].second = i;
+ emit warning_confusing_stl();
}
+ free(buffer);
- // Sort the set of vertices (to deduplicate)
- std::sort(verts.begin(), verts.end());
+ return mesh_from_verts(tri_count, verts);
+}
- // This vector will store triangles as sets of 3 indices
- std::vector<GLuint> indices(tri_count*3);
+Mesh* Loader::read_stl_ascii(QFile& file)
+{
+ file.readLine();
+ uint32_t tri_count = 0;
+ QVector<Vec3i> verts(tri_count*3);
- // Go through the sorted vertex list, deduplicating and creating
- // an indexed geometry representation for the triangles.
- // Unique vertices are moved so that they occupy the first vertex_count
- // positions in the verts array.
- size_t vertex_count = 0;
- for (auto v : verts)
+ bool okay = true;
+ while (!file.atEnd() && okay)
{
- if (!vertex_count || v.first != verts[vertex_count-1].first)
+ const auto line = file.readLine().simplified();
+ if (line.startsWith("endsolid"))
{
- verts[vertex_count++] = v;
+ break;
}
- indices[v.second] = vertex_count - 1;
+ else if (!line.startsWith("facet normal") ||
+ !file.readLine().simplified().startsWith("outer loop"))
+ {
+ okay = false;
+ break;
+ }
+
+ for (int i=0; i < 3; ++i)
+ {
+ auto line = file.readLine().simplified().split(' ');
+ if (line[0] != "vertex")
+ {
+ okay = false;
+ break;
+ }
+ const float x = line[1].toFloat(&okay);
+ const float y = line[2].toFloat(&okay);
+ const float z = line[3].toFloat(&okay);
+ verts.push_back({{x, y, z}, 0});
+ }
+ if (!file.readLine().trimmed().startsWith("endloop") ||
+ !file.readLine().trimmed().startsWith("endfacet"))
+ {
+ okay = false;
+ break;
+ }
+ tri_count++;
}
- verts.resize(vertex_count);
- std::vector<GLfloat> flat_verts;
- flat_verts.reserve(vertex_count*3);
- for (auto v : verts)
+ if (okay)
{
- flat_verts.push_back(v.first.x);
- flat_verts.push_back(v.first.y);
- flat_verts.push_back(v.first.z);
+ return mesh_from_verts(tri_count, verts);
+ }
+ else
+ {
+ emit error_bad_stl();
+ return NULL;
}
-
- return new Mesh(flat_verts, indices);
}
{
Q_OBJECT
public:
- explicit Loader(QObject* parent, const QString& filename);
+ explicit Loader(QObject* parent, const QString& filename, bool is_reload);
void run();
protected:
Mesh* load_stl();
+ /* Reads an ASCII stl, starting from the start of the file*/
+ Mesh* read_stl_ascii(QFile& file);
+ /* Reads a binary stl, assuming we're at the end of the header */
+ Mesh* read_stl_binary(QFile& file);
+
signals:
void loaded_file(QString filename);
- void got_mesh(Mesh* m);
+ void got_mesh(Mesh* m, bool is_reload);
- void error_ascii_stl();
void error_bad_stl();
+ void error_empty_mesh();
+ void warning_confusing_stl();
+ void error_missing_file();
private:
const QString filename;
+ bool is_reload;
+
+ /* Used to warn on binary STLs that begin with the word 'solid'" */
+ bool confusing_stl;
};
float Mesh::min(size_t start) const
{
+ if (start >= vertices.size())
+ {
+ return -1;
+ }
float v = vertices[start];
for (size_t i=start; i < vertices.size(); i += 3)
{
float Mesh::max(size_t start) const
{
+ if (start >= vertices.size())
+ {
+ return 1;
+ }
float v = vertices[start];
for (size_t i=start; i < vertices.size(); i += 3)
{
}
return v;
}
+
+bool Mesh::empty() const
+{
+ return vertices.size() == 0;
+}
float ymax() const { return max(1); }
float zmax() const { return max(2); }
+ bool empty() const;
+
private:
std::vector<GLfloat> vertices;
std::vector<GLuint> indices;
#include "canvas.h"
#include "loader.h"
+const QString Window::RECENT_FILE_KEY = "recentFiles";
+
Window::Window(QWidget *parent) :
QMainWindow(parent),
open_action(new QAction("Open", this)),
about_action(new QAction("About", this)),
- quit_action(new QAction("Quit", this))
+ quit_action(new QAction("Quit", this)),
+ perspective_action(new QAction("Perspective", this)),
+ orthogonal_action(new QAction("Orthographic", this)),
+ reload_action(new QAction("Reload", this)),
+ autoreload_action(new QAction("Autoreload", this)),
+ recent_files(new QMenu("Open recent", this)),
+ recent_files_group(new QActionGroup(this)),
+ recent_files_clear_action(new QAction("Clear recent files", this)),
+ watcher(new QFileSystemWatcher(this))
{
setWindowTitle("fstl");
setAcceptDrops(true);
- QFile styleFile(":/qt/style.qss");
- styleFile.open( QFile::ReadOnly );
- setStyleSheet(styleFile.readAll());
-
QGLFormat format;
format.setVersion(2, 1);
format.setSampleBuffers(true);
canvas = new Canvas(format, this);
setCentralWidget(canvas);
+ QObject::connect(watcher, &QFileSystemWatcher::fileChanged,
+ this, &Window::on_watched_change);
+
open_action->setShortcut(QKeySequence::Open);
QObject::connect(open_action, &QAction::triggered,
this, &Window::on_open);
QObject::connect(quit_action, &QAction::triggered,
this, &Window::close);
+ autoreload_action->setCheckable(true);
+ autoreload_action->setChecked(true);
+ autoreload_action->setEnabled(false);
+ QObject::connect(autoreload_action, &QAction::triggered,
+ this, &Window::on_autoreload_triggered);
+
+ reload_action->setShortcut(QKeySequence::Refresh);
+ reload_action->setEnabled(false);
+ QObject::connect(reload_action, &QAction::triggered,
+ this, &Window::on_reload);
+
QObject::connect(about_action, &QAction::triggered,
this, &Window::on_about);
+ QObject::connect(recent_files_clear_action, &QAction::triggered,
+ this, &Window::on_clear_recent);
+ QObject::connect(recent_files_group, &QActionGroup::triggered,
+ this, &Window::on_load_recent);
+
+ rebuild_recent_files();
+
auto file_menu = menuBar()->addMenu("File");
file_menu->addAction(open_action);
+ file_menu->addMenu(recent_files);
+ file_menu->addSeparator();
+ file_menu->addAction(reload_action);
+ file_menu->addAction(autoreload_action);
file_menu->addAction(quit_action);
+ auto view_menu = menuBar()->addMenu("View");
+ auto projection_menu = view_menu->addMenu("Projection");
+ projection_menu->addAction(perspective_action);
+ projection_menu->addAction(orthogonal_action);
+ auto projections = new QActionGroup(projection_menu);
+ for (auto p : {perspective_action, orthogonal_action})
+ {
+ projections->addAction(p);
+ p->setCheckable(true);
+ }
+ perspective_action->setChecked(true);
+ projections->setExclusive(true);
+ QObject::connect(projections, &QActionGroup::triggered,
+ this, &Window::on_projection);
+
auto help_menu = menuBar()->addMenu("Help");
help_menu->addAction(about_action);
"<p>A fast viewer for <code>.stl</code> files.<br>"
"<a href=\"https://github.com/mkeeter/fstl\""
" style=\"color: #93a1a1;\">https://github.com/mkeeter/fstl</a></p>"
- "<p>© 2014 Matthew Keeter<br>"
+ "<p>© 2014-2017 Matthew Keeter<br>"
"<a href=\"mailto:matt.j.keeter@gmail.com\""
" style=\"color: #93a1a1;\">matt.j.keeter@gmail.com</a></p>");
}
-void Window::on_ascii_stl()
+void Window::on_bad_stl()
{
QMessageBox::critical(this, "Error",
"<b>Error:</b><br>"
- "Cannot open ASCII <code>.stl</code> file<br>"
- "Please convert to binary <code>.stl</code> and retry");
+ "This <code>.stl</code> file is invalid or corrupted.<br>"
+ "Please export it from the original source, verify, and retry.");
}
-void Window::on_bad_stl()
+void Window::on_empty_mesh()
{
QMessageBox::critical(this, "Error",
"<b>Error:</b><br>"
- "This <code>.stl</code> file is invalid or corrupted.<br>"
- "Please export it from the original source, verify, and retry.");
+ "This file is syntactically correct<br>but contains no triangles.");
+}
+
+void Window::on_confusing_stl()
+{
+ QMessageBox::warning(this, "Warning",
+ "<b>Warning:</b><br>"
+ "This <code>.stl</code> file begins with <code>solid </code>but appears to be a binary file.<br>"
+ "<code>fstl</code> loaded it, but other programs may be confused by this file.");
+}
+
+void Window::on_missing_file()
+{
+ QMessageBox::critical(this, "Error",
+ "<b>Error:</b><br>"
+ "The target file is missing.<br>");
}
void Window::enable_open()
open_action->setEnabled(false);
}
-bool Window::load_stl(const QString& filename)
+void Window::set_watched(const QString& filename)
+{
+ const auto files = watcher->files();
+ if (files.size())
+ {
+ watcher->removePaths(watcher->files());
+ }
+ watcher->addPath(filename);
+
+ QSettings settings;
+ auto recent = settings.value(RECENT_FILE_KEY).toStringList();
+ const auto f = QFileInfo(filename).absoluteFilePath();
+ recent.removeAll(f);
+ recent.prepend(f);
+ while (recent.size() > MAX_RECENT_FILES)
+ {
+ recent.pop_back();
+ }
+ settings.setValue(RECENT_FILE_KEY, recent);
+ rebuild_recent_files();
+}
+
+void Window::on_projection(QAction* proj)
+{
+ if (proj == perspective_action)
+ {
+ canvas->view_perspective();
+ }
+ else
+ {
+ canvas->view_orthographic();
+ }
+}
+
+void Window::on_watched_change(const QString& filename)
+{
+ if (autoreload_action->isChecked())
+ {
+ load_stl(filename, true);
+ }
+}
+
+void Window::on_autoreload_triggered(bool b)
+{
+ if (b)
+ {
+ on_reload();
+ }
+}
+
+void Window::on_clear_recent()
+{
+ QSettings settings;
+ settings.setValue(RECENT_FILE_KEY, QStringList());
+ rebuild_recent_files();
+}
+
+void Window::on_load_recent(QAction* a)
+{
+ load_stl(a->data().toString());
+}
+
+void Window::rebuild_recent_files()
+{
+ QSettings settings;
+ QStringList files = settings.value(RECENT_FILE_KEY).toStringList();
+
+ const auto actions = recent_files_group->actions();
+ for (auto a : actions)
+ {
+ recent_files_group->removeAction(a);
+ }
+ recent_files->clear();
+
+ for (auto f : files)
+ {
+ const auto a = new QAction(f, recent_files);
+ a->setData(f);
+ recent_files_group->addAction(a);
+ recent_files->addAction(a);
+ }
+ if (files.size() == 0)
+ {
+ auto a = new QAction("No recent files", recent_files);
+ recent_files->addAction(a);
+ a->setEnabled(false);
+ }
+ recent_files->addSeparator();
+ recent_files->addAction(recent_files_clear_action);
+}
+
+void Window::on_reload()
+{
+ auto fs = watcher->files();
+ if (fs.size() == 1)
+ {
+ load_stl(fs[0], true);
+ }
+}
+
+bool Window::load_stl(const QString& filename, bool is_reload)
{
if (!open_action->isEnabled()) return false;
canvas->set_status("Loading " + filename);
- Loader* loader = new Loader(this, filename);
+ Loader* loader = new Loader(this, filename, is_reload);
connect(loader, &Loader::started,
this, &Window::disable_open);
connect(loader, &Loader::got_mesh,
canvas, &Canvas::load_mesh);
- connect(loader, &Loader::error_ascii_stl,
- this, &Window::on_ascii_stl);
connect(loader, &Loader::error_bad_stl,
this, &Window::on_bad_stl);
+ connect(loader, &Loader::error_empty_mesh,
+ this, &Window::on_empty_mesh);
+ connect(loader, &Loader::warning_confusing_stl,
+ this, &Window::on_confusing_stl);
+ connect(loader, &Loader::error_missing_file,
+ this, &Window::on_missing_file);
connect(loader, &Loader::finished,
loader, &Loader::deleteLater);
{
connect(loader, &Loader::loaded_file,
this, &Window::setWindowTitle);
+ connect(loader, &Loader::loaded_file,
+ this, &Window::set_watched);
+ autoreload_action->setEnabled(true);
+ reload_action->setEnabled(true);
}
loader->start();
#define WINDOW_H
#include <QMainWindow>
+#include <QActionGroup>
+#include <QFileSystemWatcher>
class Canvas;
Q_OBJECT
public:
explicit Window(QWidget* parent=0);
- bool load_stl(const QString& filename);
+ bool load_stl(const QString& filename, bool is_reload=false);
protected:
void dragEnterEvent(QDragEnterEvent* event);
public slots:
void on_open();
void on_about();
- void on_ascii_stl();
void on_bad_stl();
+ void on_empty_mesh();
+ void on_missing_file();
+ void on_confusing_stl();
void enable_open();
void disable_open();
+ void set_watched(const QString& filename);
+
+private slots:
+ void on_projection(QAction* proj);
+ void on_watched_change(const QString& filename);
+ void on_reload();
+ void on_autoreload_triggered(bool r);
+ void on_clear_recent();
+ void on_load_recent(QAction* a);
+
private:
+ void rebuild_recent_files();
+
QAction* const open_action;
QAction* const about_action;
QAction* const quit_action;
+ QAction* const perspective_action;
+ QAction* const orthogonal_action;
+ QAction* const reload_action;
+ QAction* const autoreload_action;
+
+ QMenu* const recent_files;
+ QActionGroup* const recent_files_group;
+ QAction* const recent_files_clear_action;
+ const static int MAX_RECENT_FILES=8;
+ const static QString RECENT_FILE_KEY;
+
+ QFileSystemWatcher* watcher;
Canvas* canvas;
};