diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..073f8d5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +*.o +Makefile +moc +obj +rcc +ui +output +.qmake.stash +brickr +moc_*.cpp +moc_*.o +moc_predefs.h +ui_*.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0841427 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.o +Makefile +brickr +moc_*.cpp +moc_*.o +moc_predefs.h +ui_*.h +.qmake.stash +models/*_scaled.obj +models/*_scaled.binvox +models/*.binvox +output/* +!output/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db8b27a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +FROM --platform=linux/amd64 ubuntu:22.04 AS build + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + libboost-graph-dev \ + libglu1-mesa-dev \ + libqt5opengl5-dev \ + libqt5svg5-dev \ + mesa-common-dev \ + qt5-qmake \ + qtbase5-dev \ + qtbase5-dev-tools \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +COPY . . + +RUN qmake brickr.pro && make -j"$(nproc)" + + +FROM --platform=linux/amd64 ubuntu:22.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libboost-graph1.74.0 \ + libgl1-mesa-glx \ + libgl1-mesa-dri \ + libglu1-mesa \ + libqt5core5a \ + libqt5gui5 \ + libqt5opengl5 \ + libqt5svg5 \ + libqt5widgets5 \ + novnc \ + python3-pip \ + websockify \ + xauth \ + x11vnc \ + xvfb \ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m pip install --no-cache-dir trimesh + +WORKDIR /opt/brickr + +COPY --from=build /src/brickr /opt/brickr/brickr +COPY --from=build /src/resources /opt/brickr/resources +COPY --from=build /src/models /opt/brickr/models +COPY tools/obj_to_binvox.py /opt/brickr/tools/obj_to_binvox.py +COPY docker/entrypoint-vnc.sh /opt/brickr/entrypoint-vnc.sh + +ENV BINVOX_PATH=/opt/brickr/tools/obj_to_binvox.py +RUN chmod +x /opt/brickr/entrypoint-vnc.sh + +EXPOSE 5900 6080 + +CMD ["/opt/brickr/entrypoint-vnc.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f588689 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# brickr in Docker + +This workspace packages [Daekkyn/brickr](https://github.com/Daekkyn/brickr) in Docker so the Qt/OpenGL app can be built reproducibly on Linux. + +## What changed + +- Added a `Dockerfile` to build and run the app in Ubuntu 22.04. +- Added `docker-compose.yml` for a one-command local setup. +- Replaced the bundled `binvox` runtime path with a Python voxelizer that works on Apple Silicon. +- Added a browser-based noVNC desktop so the Qt GUI can be used on macOS without XQuartz. +- Fixed BINVOX import dimension handling so generated voxel files load correctly. + +## Build + +```sh +docker build --platform=linux/amd64 -t brickr:local . +``` + +## Run + +This image starts Brickr in a virtual desktop and exposes it in your browser with noVNC: + +```sh +docker run --rm -it --platform=linux/amd64 -p 6080:6080 -p 5900:5900 brickr:local +``` + +Then open: + +```txt +http://localhost:6080/vnc.html +``` + +To work with your own meshes, mount the repo or a models folder: + +```sh +docker run --rm -it \ + --platform=linux/amd64 \ + -p 6080:6080 \ + -p 5900:5900 \ + -v "$PWD/models:/opt/brickr/models" \ + -v "$PWD/output:/opt/brickr/output" \ + brickr:local +``` + +Or with Compose: + +```sh +mkdir -p output +docker compose up --build +``` + +## CLI + +You can also run Brickr headlessly from Docker: + +```sh +docker run --rm \ + --platform=linux/amd64 \ + --entrypoint /opt/brickr/brickr \ + -e BINVOX_PATH=/opt/brickr/tools/obj_to_binvox.py \ + -e QT_QPA_PLATFORM=offscreen \ + -v "$PWD/output:/opt/brickr/output" \ + brickr:local \ + --cli \ + --input /opt/brickr/models/toyplane.obj \ + --resolution 30 \ + --print-stats \ + --export-obj /opt/brickr/output/toyplane.obj \ + --save-instructions /opt/brickr/output/toyplane.svg +``` + +CLI flags: + +- `--input`: input `.obj` or `.binvox` +- `--resolution`: voxelization resolution for mesh inputs +- `--pre-hollow`: run pre-hollowing before optimization +- `--shell-thickness`: shell thickness used with `--pre-hollow` +- `--auto-optimize`: run Brickr's auto optimizer +- `--finalize`: post-hollow, solve limits, and merge +- `--print-stats`: print LEGO model stats +- `--export-obj`: export the generated brick model as `.obj` +- `--save-instructions`: export one instruction file per layer as `.svg`, `.png`, or `.jpg` + +## Notes + +- `brickr` is a GUI app, not a command-line converter. +- Mesh input support is currently `.obj`. +- The bundled `binvox` binary is Linux x86_64, so the container is pinned to `linux/amd64`. +- `BINVOX_PATH` is set automatically to the Python voxelizer inside the image. +- The easiest way to see the GUI on macOS is the built-in noVNC desktop at `http://localhost:6080/vnc.html`. +- In the browser container, exports are written into `/opt/brickr/output`, which maps to local `./output`. diff --git a/brickr.pro b/brickr.pro index f8b177d..470619e 100644 --- a/brickr.pro +++ b/brickr.pro @@ -13,6 +13,7 @@ QMAKE_CXXFLAGS += -std=c++11 # Input HEADERS += src/AssemblyPlugin.h \ src/AssemblyWidget.h \ + src/BrickrCli.h \ src/LegoBrick.h \ src/LegoCloud.h \ src/LegoCloudNode.h \ @@ -25,6 +26,7 @@ HEADERS += src/AssemblyPlugin.h \ FORMS += forms/AssemblyWidget.ui SOURCES += src/AssemblyPlugin.cpp \ src/AssemblyWidget.cpp \ + src/BrickrCli.cpp \ src/LegoCloud.cpp \ src/LegoCloudNode.cpp \ src/main.cpp \ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..89ffda0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + brickr: + platform: linux/amd64 + build: + context: . + image: brickr:local + environment: + BINVOX_PATH: /opt/brickr/tools/obj_to_binvox.py + BRICKR_OUTPUT_DIR: /opt/brickr/output + volumes: + - ./models:/opt/brickr/models + - ./output:/opt/brickr/output + ports: + - "5900:5900" + - "6080:6080" diff --git a/docker/entrypoint-vnc.sh b/docker/entrypoint-vnc.sh new file mode 100644 index 0000000..040b26b --- /dev/null +++ b/docker/entrypoint-vnc.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY="${DISPLAY:-:99}" +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/runtime-root}" +export BINVOX_PATH="${BINVOX_PATH:-/opt/brickr/tools/obj_to_binvox.py}" +export BRICKR_OPEN_FILE="${BRICKR_OPEN_FILE:-/opt/brickr/models/toyplane.obj}" +export BRICKR_VOX_RES="${BRICKR_VOX_RES:-30}" +export BRICKR_OUTPUT_DIR="${BRICKR_OUTPUT_DIR:-/opt/brickr/output}" +export LIBGL_ALWAYS_SOFTWARE="${LIBGL_ALWAYS_SOFTWARE:-1}" +export GALLIUM_DRIVER="${GALLIUM_DRIVER:-softpipe}" +export MESA_LOADER_DRIVER_OVERRIDE="${MESA_LOADER_DRIVER_OVERRIDE:-llvmpipe}" + +mkdir -p "$XDG_RUNTIME_DIR" +chmod 700 "$XDG_RUNTIME_DIR" + +Xvfb "$DISPLAY" -screen 0 1440x960x24 -ac +extension GLX +render -noreset & +XVFB_PID=$! + +x11vnc -display "$DISPLAY" -forever -shared -nopw -listen 0.0.0.0 -xkb >/tmp/x11vnc.log 2>&1 & +X11VNC_PID=$! + +websockify --web=/usr/share/novnc/ 6080 localhost:5900 >/tmp/websockify.log 2>&1 & +WEBSOCKIFY_PID=$! + +sleep 2 + +/opt/brickr/brickr >/tmp/brickr.log 2>&1 & +BRICKR_PID=$! + +cleanup() { + kill "$BRICKR_PID" "$WEBSOCKIFY_PID" "$X11VNC_PID" "$XVFB_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "Brickr desktop available at http://localhost:6080/vnc.html" +echo "VNC port available at localhost:5900" +echo "Startup mesh: $BRICKR_OPEN_FILE (resolution $BRICKR_VOX_RES)" +echo "Output directory: $BRICKR_OUTPUT_DIR" + +wait "$BRICKR_PID" diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/output/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/AssemblyPlugin.cpp b/src/AssemblyPlugin.cpp index 8561bcc..34471e5 100755 --- a/src/AssemblyPlugin.cpp +++ b/src/AssemblyPlugin.cpp @@ -227,7 +227,11 @@ bool AssemblyPlugin::parseBinvox(const std::string& filename, LegoCloudNode* leg } size = width * height * depth; - legoCloudNode->getLegoCloud()->setVoxelGridDimmension(height, width, depth); + // The binvox stream is decoded into (level, x, y) where x ranges over the + // header's first dimension ("depth") and y ranges over the third ("width"). + // The original code only worked for square footprints because it swapped + // those horizontal dimensions when allocating the voxel grid. + legoCloudNode->getLegoCloud()->setVoxelGridDimmension(height, depth, width); if(colorFile.exists()) { if(colorFile.open(QIODevice::ReadOnly)) { @@ -407,4 +411,3 @@ void AssemblyPlugin::draw() legoCloudNode_->render(); } } - diff --git a/src/AssemblyWidget.cpp b/src/AssemblyWidget.cpp index 1f0c5eb..57ea290 100755 --- a/src/AssemblyWidget.cpp +++ b/src/AssemblyWidget.cpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include #include //#define STATISTICS @@ -30,6 +33,67 @@ AssemblyWidget::AssemblyWidget(AssemblyPlugin* _plugin, QWidget* _parent) AssemblyWidget::~AssemblyWidget() { } +QString AssemblyWidget::getOpenFilePath(const QString &title, const QString &initialPath, const QString &filter) +{ + QFileDialog dialog(this, title, initialPath, filter); + dialog.setOption(QFileDialog::DontUseNativeDialog, true); + dialog.setFileMode(QFileDialog::ExistingFile); + + if(dialog.exec() != QDialog::Accepted || dialog.selectedFiles().isEmpty()) + return QString(); + + return dialog.selectedFiles().first(); +} + +QString AssemblyWidget::getSaveFilePath(const QString &title, const QString &initialPath, const QString &filter) +{ + QFileDialog dialog(this, title, initialPath, filter); + dialog.setOption(QFileDialog::DontUseNativeDialog, true); + dialog.setAcceptMode(QFileDialog::AcceptSave); + + if(dialog.exec() != QDialog::Accepted || dialog.selectedFiles().isEmpty()) + return QString(); + + return dialog.selectedFiles().first(); +} + +QString AssemblyWidget::getCurrentModelBaseName() const +{ + QSettings settings; + QString currentFile = settings.value("AssemblyPlugin::LoadFile", "").toString(); + QFileInfo currentFileInfo(currentFile); + + if(currentFileInfo.baseName().isEmpty()) + return QString("brickr_model"); + + return currentFileInfo.baseName(); +} + +QString AssemblyWidget::getAutoSaveBasePath(const QString &suffix) const +{ + const QString outputDirPath = qEnvironmentVariable("BRICKR_OUTPUT_DIR"); + if(outputDirPath.isEmpty()) + return QString(); + + QDir outputDir(outputDirPath); + if(!outputDir.exists()) + outputDir.mkpath("."); + + const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); + const QString baseName = getCurrentModelBaseName(); + + return outputDir.filePath(baseName + "_" + timestamp + "." + suffix); +} + +void AssemblyWidget::openFile(const QString &filePath, int voxelizationResolution) +{ + if(filePath.isNull()) + return; + + loadFile(filePath, voxelizationResolution); + resetUi(); +} + void AssemblyWidget::setMaxLayerSpinBox(int max) { layerSpinBox->setMaximum(max); @@ -121,7 +185,7 @@ void AssemblyWidget::on_loadFileButton_pressed() QSettings settings; QString lastOpenedFile = settings.value("AssemblyPlugin::LoadFile", "").toString(); - QString selectedFilePath = QFileDialog::getOpenFileName(this, "Open File", lastOpenedFile); + QString selectedFilePath = getOpenFilePath("Open File", lastOpenedFile); if(selectedFilePath.isNull()) { @@ -158,7 +222,6 @@ void AssemblyWidget::on_loadFileButton_pressed() #endif - resetUi(); } /* @@ -359,15 +422,21 @@ void AssemblyWidget::on_saveInstructionsButton_pressed() QSettings settings; QString lastSavedFile = settings.value("AssemblyPlugin::SaveInstruction", "").toString(); - QString filePathBase = QFileDialog::getSaveFileName(this, "Save instructions (.png .jpg or .svg)", lastSavedFile, "Images (*.png *.jpg *.svg)"); - if(filePathBase == NULL) + QString filePathBase = getAutoSaveBasePath("svg"); + if(filePathBase.isEmpty()) { - //User canceled - return; + filePathBase = getSaveFilePath("Save instructions (.png .jpg or .svg)", lastSavedFile, "Images (*.png *.jpg *.svg)"); + if(filePathBase == NULL) + { + //User canceled + return; + } } settings.setValue("AssemblyPlugin::SaveInstruction", filePathBase); + std::cout << "Saving instructions base: " << filePathBase.toStdString() << std::endl; + QFileInfo fileInfo(filePathBase); bool useSVG = fileInfo.suffix().compare("svg", Qt::CaseInsensitive) == 0; @@ -422,13 +491,16 @@ void AssemblyWidget::on_objExportButton_pressed() if(!legoCloudNode) return; - QString filename = QFileDialog::getSaveFileName(this, "Save as", "", "Obj (*.obj)"); + QString filename = getAutoSaveBasePath("obj"); + if(filename.isEmpty()) + filename = getSaveFilePath("Save as", "", "Obj (*.obj)"); if(filename.isNull()) { return; } + std::cout << "Exporting OBJ to: " << filename.toStdString() << std::endl; legoCloudNode->exportToObj(filename); } @@ -578,11 +650,32 @@ void AssemblyWidget::loadFile(const QString &filePath, int voxelizationResolutio binvoxFile.remove(); } - //First we try to find it in its default location + // Try bundled locations first so containerized Linux builds work without prompting. #ifdef WIN32 QFileInfo binvoxProgramFileInfo(QCoreApplication::applicationDirPath() + "/binvox.exe"); #else - QFileInfo binvoxProgramFileInfo(QCoreApplication::applicationDirPath() + "/../Resources/binvox"); + const QString appDir = QCoreApplication::applicationDirPath(); + const QStringList defaultBinvoxPaths = { + qEnvironmentVariable("BINVOX_PATH"), + appDir + "/binvox", + appDir + "/resources/binvox", + appDir + "/../resources/binvox", + appDir + "/../Resources/binvox" + }; + + QFileInfo binvoxProgramFileInfo; + foreach (const QString &candidatePath, defaultBinvoxPaths) + { + if(candidatePath.isEmpty()) + continue; + + QFileInfo candidateInfo(candidatePath); + if(candidateInfo.exists()) + { + binvoxProgramFileInfo = candidateInfo; + break; + } + } #endif // std::cout << qPrintable(QCoreApplication::applicationFilePath()) << std::endl; @@ -595,7 +688,7 @@ void AssemblyWidget::loadFile(const QString &filePath, int voxelizationResolutio if(!binvoxProgramFileInfo.exists()) { //If it is not found in the last specified location; we ask the user - QString binvoxFilePath = QFileDialog::getOpenFileName(this, "Locate binvox executable"); + QString binvoxFilePath = getOpenFilePath("Locate binvox executable", ""); if(binvoxFilePath.isNull()) { std::cerr << "The binvox executable was not found." << std::endl; @@ -622,7 +715,18 @@ void AssemblyWidget::loadFile(const QString &filePath, int voxelizationResolutio #ifdef WIN32 QString command("\"" + binvoxProgramFileInfo.absoluteFilePath() + "\" -d "+ QString::number(voxelizationResolution) + " \"" + scaledFilePath + "\""); #else - QString command("\"" + binvoxProgramFileInfo.absoluteFilePath()+ "\" -pb -d "+ QString::number(voxelizationResolution) + " \"" +scaledFilePath + "\""); + QString command; + if(binvoxProgramFileInfo.suffix().compare("py", Qt::CaseInsensitive) == 0) + { + command = "python3 \"" + binvoxProgramFileInfo.absoluteFilePath() + "\" -d " + + QString::number(voxelizationResolution) + " \"" + scaledFilePath + "\" \"" + + binvoxProgramOutputFile.fileName() + "\""; + } + else + { + command = "\"" + binvoxProgramFileInfo.absoluteFilePath()+ "\" -pb -d " + + QString::number(voxelizationResolution) + " \"" +scaledFilePath + "\""; + } #endif // QString command(binvoxProgramFileInfo.absoluteFilePath()+ " -d "+ QString::number(voxelizationResolution) + " " +filePath); std::cout << "Running " << qPrintable(command) << std::endl; @@ -720,4 +824,3 @@ void AssemblyWidget::setBrickLimit(BrickSize size, int value) legoCloudNode->getLegoCloud()->setBrickLimit(size, value); } - diff --git a/src/AssemblyWidget.h b/src/AssemblyWidget.h index d752ce6..06c3b6b 100755 --- a/src/AssemblyWidget.h +++ b/src/AssemblyWidget.h @@ -18,6 +18,7 @@ class AssemblyWidget: public QWidget, private Ui_AssemblyWidget { ~AssemblyWidget(); void setMaxLayerSpinBox(int max); + void openFile(const QString& filePath, int voxelizationResolution = 0); private slots: void on_testButton_pressed(); @@ -72,6 +73,10 @@ private slots: void loadFile(const QString& filePath, int voxelizationResolution = 0); bool isMeshExtensionSupported(const QString& extension) const; void scaleMesh(const QString &filePath, const QString &scaledFilePath); + QString getOpenFilePath(const QString &title, const QString &initialPath, const QString &filter = QString()); + QString getSaveFilePath(const QString &title, const QString &initialPath, const QString &filter = QString()); + QString getAutoSaveBasePath(const QString &suffix) const; + QString getCurrentModelBaseName() const; AssemblyPlugin *plugin_; }; diff --git a/src/BrickrCli.cpp b/src/BrickrCli.cpp new file mode 100644 index 0000000..3af5090 --- /dev/null +++ b/src/BrickrCli.cpp @@ -0,0 +1,297 @@ +#include "BrickrCli.h" + +#include "AssemblyPlugin.h" +#include "LegoCloud.h" +#include "LegoCloudNode.h" +#include "Vector3.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +const int BRICK_PIXEL_SIZE = 20; +} + +int BrickrCli::run(const BrickrCliOptions &options) +{ + AssemblyPlugin plugin; + + if(!loadInput(&plugin, options)) + return 1; + + LegoCloudNode *legoCloudNode = plugin.getLegoCloudNode(); + if(!legoCloudNode) + { + std::cerr << "Failed to build the LEGO model." << std::endl; + return 1; + } + + if(options.autoOptimize) + plugin.autoOptimize(); + + if(options.finalize) + { + legoCloudNode->getLegoCloud()->postHollow(); + legoCloudNode->getLegoCloud()->solveBrickNumberLimitation(); + legoCloudNode->getLegoCloud()->merge(); + legoCloudNode->nodeUpdated(); + std::cout << "Finalization done." << std::endl; + } + + if(options.printStats) + legoCloudNode->getLegoCloud()->printStats(); + + if(!options.exportObjPath.isEmpty()) + { + std::cout << "Exporting OBJ to: " << qPrintable(options.exportObjPath) << std::endl; + legoCloudNode->exportToObj(options.exportObjPath); + } + + if(!options.saveInstructionsPath.isEmpty() && !saveInstructions(legoCloudNode, options.saveInstructionsPath)) + return 1; + + return 0; +} + +bool BrickrCli::loadInput(AssemblyPlugin *plugin, const BrickrCliOptions &options) +{ + QFileInfo selectedFileInfo(options.inputPath); + + if(!selectedFileInfo.exists() || !selectedFileInfo.isReadable()) + { + std::cerr << "Unable to open file: " << options.inputPath.toStdString() << std::endl; + return false; + } + + QString binvoxFilePath; + if(isMeshExtensionSupported(selectedFileInfo.suffix())) + { + if(options.voxelizationResolution <= 0) + { + std::cerr << "Voxelization resolution must be positive." << std::endl; + return false; + } + + const QString scaledFilePath = selectedFileInfo.absolutePath() + "/_" + selectedFileInfo.baseName() + "_scaled.obj"; + + { + QFile scaledFile(scaledFilePath); + scaledFile.remove(); + } + + if(!scaleMesh(options.inputPath, scaledFilePath)) + return false; + + QFileInfo scaledFileInfo(scaledFilePath); + binvoxFilePath = selectedFileInfo.absolutePath() + "/" + selectedFileInfo.baseName() + QString::number(options.voxelizationResolution) + ".binvox"; + QFile binvoxFile(binvoxFilePath); + if(binvoxFile.exists()) + binvoxFile.remove(); + + const QString voxelizerPath = locateVoxelizer(); + if(voxelizerPath.isEmpty()) + { + std::cerr << "Unable to locate a voxelizer. Set BINVOX_PATH to a binvox binary or the Python helper." << std::endl; + return false; + } + + QFileInfo voxelizerInfo(voxelizerPath); + QFile generatedBinvoxFile(scaledFileInfo.absolutePath() + "/" + scaledFileInfo.baseName() + ".binvox"); + if(generatedBinvoxFile.exists()) + generatedBinvoxFile.remove(); + + QString command; +#ifdef WIN32 + command = "\"" + voxelizerInfo.absoluteFilePath() + "\" -d " + QString::number(options.voxelizationResolution) + " \"" + scaledFilePath + "\""; +#else + if(voxelizerInfo.suffix().compare("py", Qt::CaseInsensitive) == 0) + { + command = "python3 \"" + voxelizerInfo.absoluteFilePath() + "\" -d " + + QString::number(options.voxelizationResolution) + " \"" + scaledFilePath + "\" \"" + + generatedBinvoxFile.fileName() + "\""; + } + else + { + command = "\"" + voxelizerInfo.absoluteFilePath() + "\" -pb -d " + + QString::number(options.voxelizationResolution) + " \"" + scaledFilePath + "\""; + } +#endif + + std::cout << "Running " << qPrintable(command) << std::endl; + + QProcess process; + process.start(command); + process.waitForFinished(-1); + std::cerr << process.readAllStandardError().data() << std::endl; + + if(!generatedBinvoxFile.exists()) + { + std::cerr << "The mesh could not be voxelized." << std::endl; + return false; + } + + generatedBinvoxFile.rename(binvoxFile.fileName()); + + QFile scaledFile(scaledFilePath); + scaledFile.remove(); + } + else if(selectedFileInfo.suffix().compare("binvox", Qt::CaseInsensitive) == 0) + { + binvoxFilePath = options.inputPath; + } + else + { + std::cerr << "Unsupported input extension: " << selectedFileInfo.suffix().toStdString() << std::endl; + return false; + } + + plugin->loadVoxelization(binvoxFilePath); + + LegoCloudNode *legoCloudNode = plugin->getLegoCloudNode(); + if(!legoCloudNode) + return false; + + if(options.preHollow) + legoCloudNode->getLegoCloud()->preHollow(options.shellThickness); + + return true; +} + +bool BrickrCli::isMeshExtensionSupported(const QString &extension) +{ + return extension.compare("obj", Qt::CaseInsensitive) == 0; +} + +bool BrickrCli::scaleMesh(const QString &filePath, const QString &scaledFilePath) +{ + QFile infile(filePath); + if(!infile.open(QFile::ReadOnly)) + { + std::cerr << "Unable to read file: " << filePath.toStdString() << std::endl; + return false; + } + + QFile outfile(scaledFilePath); + if(!outfile.open(QFile::WriteOnly | QFile::Truncate)) + { + std::cerr << "Unable to create scaled file: " << scaledFilePath.toStdString() << std::endl; + return false; + } + + QTextStream in(&infile); + QTextStream out(&outfile); + + while(!in.atEnd()) + { + QString input = in.readLine(); + if(input.isEmpty() || input[0] == '#') + continue; + + QTextStream ts(&input); + QString id; + ts >> id; + if(id == "v") + { + Vector3 p; + for(int i = 0; i < 3; ++i) + ts >> p[i]; + + out << "v " << p[0] << " " << 0.83333333 * p[1] << " " << p[2] << "\n"; + } + else + { + out << input << "\n"; + } + } + + return true; +} + +QString BrickrCli::locateVoxelizer() +{ +#ifdef WIN32 + QFileInfo voxelizerInfo(QCoreApplication::applicationDirPath() + "/binvox.exe"); + return voxelizerInfo.exists() ? voxelizerInfo.absoluteFilePath() : QString(); +#else + const QString appDir = QCoreApplication::applicationDirPath(); + const QStringList candidates = { + qEnvironmentVariable("BINVOX_PATH"), + appDir + "/binvox", + appDir + "/resources/binvox", + appDir + "/../resources/binvox", + appDir + "/../Resources/binvox" + }; + + foreach(const QString &candidatePath, candidates) + { + if(candidatePath.isEmpty()) + continue; + + QFileInfo candidateInfo(candidatePath); + if(candidateInfo.exists()) + return candidateInfo.absoluteFilePath(); + } + + return QString(); +#endif +} + +bool BrickrCli::saveInstructions(LegoCloudNode *legoCloudNode, const QString &filePathBase) +{ + QFileInfo fileInfo(filePathBase); + const bool useSVG = fileInfo.suffix().compare("svg", Qt::CaseInsensitive) == 0; + + if(fileInfo.suffix().isEmpty()) + { + std::cerr << "Instruction output path must include an extension (.svg, .png, or .jpg)." << std::endl; + return false; + } + + QGraphicsScene scene; + + for(int level = 0; level < legoCloudNode->getLegoCloud()->getLevelNumber(); level++) + { + legoCloudNode->setRenderLayer(level); + legoCloudNode->drawInstructions(&scene, !useSVG); + + const int imageSizeX = legoCloudNode->getLegoCloud()->getWidth() * BRICK_PIXEL_SIZE; + const int imageSizeY = legoCloudNode->getLegoCloud()->getDepth() * BRICK_PIXEL_SIZE; + const QString filePathLevel = fileInfo.absolutePath() + "/" + fileInfo.baseName() + "_" + + QString::number(level) + "." + fileInfo.completeSuffix(); + + std::cout << "Saving: " << filePathLevel.toStdString() << std::endl; + + if(useSVG) + { + QSvgGenerator svgGen; + svgGen.setFileName(filePathLevel); + svgGen.setSize(QSize(imageSizeX, imageSizeY)); + svgGen.setViewBox(QRect(0, 0, imageSizeX, imageSizeY)); + + QPainter painter(&svgGen); + scene.render(&painter); + } + else + { + QImage image(QSize(imageSizeX, imageSizeY), QImage::Format_ARGB32_Premultiplied); + image.fill(QColor("White")); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + scene.render(&painter); + image.save(filePathLevel); + } + } + + return true; +} diff --git a/src/BrickrCli.h b/src/BrickrCli.h new file mode 100644 index 0000000..a5680c6 --- /dev/null +++ b/src/BrickrCli.h @@ -0,0 +1,45 @@ +#ifndef BRICKR_CLI_H +#define BRICKR_CLI_H + +#include + +class AssemblyPlugin; +class LegoCloudNode; + +struct BrickrCliOptions +{ + QString inputPath; + int voxelizationResolution; + bool preHollow; + int shellThickness; + bool autoOptimize; + bool finalize; + bool printStats; + QString exportObjPath; + QString saveInstructionsPath; + + BrickrCliOptions() + : voxelizationResolution(30), + preHollow(false), + shellThickness(2), + autoOptimize(false), + finalize(false), + printStats(false) + { + } +}; + +class BrickrCli +{ +public: + static int run(const BrickrCliOptions &options); + +private: + static bool loadInput(AssemblyPlugin *plugin, const BrickrCliOptions &options); + static bool isMeshExtensionSupported(const QString &extension); + static bool scaleMesh(const QString &filePath, const QString &scaledFilePath); + static QString locateVoxelizer(); + static bool saveInstructions(LegoCloudNode *legoCloudNode, const QString &filePathBase); +}; + +#endif diff --git a/src/Vector3.h b/src/Vector3.h index fc71d51..614ba09 100644 --- a/src/Vector3.h +++ b/src/Vector3.h @@ -1,9 +1,13 @@ #ifndef Vector3_H #define Vector3_H -#include "math.h" +#include +#include #include + +#ifndef Q_MOC_RUN #include +#endif struct Vector3 { @@ -86,7 +90,7 @@ struct Vector3 float norm() const { - return sqrt(x() * x() + y() * y() + z() * z()); + return std::sqrt(x() * x() + y() * y() + z() * z()); } float squaredNorm() const @@ -123,11 +127,13 @@ struct Vector3 return data_[index]; } +#ifndef Q_MOC_RUN friend std::ostream& operator<< (std::ostream& stream, const Vector3& a) { stream << a.x() << " " << a.y() << " " << a.z(); return stream; } +#endif }; inline float dot(const Vector3 &a, const Vector3 &b) diff --git a/src/main.cpp b/src/main.cpp index def38db..187d40b 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,13 @@ #include "openglscene.h" +#include "BrickrCli.h" #include #include #include #include +#include +#include +#include class GraphicsView : public QGraphicsView { @@ -23,12 +27,86 @@ class GraphicsView : public QGraphicsView int main(int argc, char **argv) { + bool cliMode = false; + for (int i = 1; i < argc; ++i) + { + if (QString::fromLocal8Bit(argv[i]) == "--cli") + { + cliMode = true; + break; + } + } + + if (cliMode && qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", QByteArray("offscreen")); + QApplication app(argc, argv); + QCommandLineParser parser; + parser.setApplicationDescription("Brickr"); + parser.addHelpOption(); + + QCommandLineOption cliOption("cli", "Run Brickr in command-line mode without opening the GUI."); + QCommandLineOption inputOption(QStringList() << "i" << "input", "Input OBJ or BINVOX file.", "path"); + QCommandLineOption resolutionOption(QStringList() << "r" << "resolution", "Voxelization resolution for mesh inputs.", "value", "30"); + QCommandLineOption preHollowOption("pre-hollow", "Run pre-hollowing before optimization."); + QCommandLineOption shellThicknessOption("shell-thickness", "Shell thickness used with --pre-hollow.", "value", "2"); + QCommandLineOption autoOptimizeOption("auto-optimize", "Run Brickr's auto optimization."); + QCommandLineOption finalizeOption("finalize", "Run finalization (post-hollow, solve limits, merge)."); + QCommandLineOption printStatsOption("print-stats", "Print model statistics to stdout."); + QCommandLineOption exportObjOption("export-obj", "Write the generated LEGO model as OBJ.", "path"); + QCommandLineOption saveInstructionsOption("save-instructions", "Write per-layer instruction files using the given extension (.svg, .png, or .jpg).", "path"); + + parser.addOption(cliOption); + parser.addOption(inputOption); + parser.addOption(resolutionOption); + parser.addOption(preHollowOption); + parser.addOption(shellThicknessOption); + parser.addOption(autoOptimizeOption); + parser.addOption(finalizeOption); + parser.addOption(printStatsOption); + parser.addOption(exportObjOption); + parser.addOption(saveInstructionsOption); + parser.process(app); + + if (parser.isSet(cliOption)) + { + BrickrCliOptions options; + options.inputPath = parser.value(inputOption); + options.voxelizationResolution = parser.value(resolutionOption).toInt(); + options.preHollow = parser.isSet(preHollowOption); + options.shellThickness = parser.value(shellThicknessOption).toInt(); + options.autoOptimize = parser.isSet(autoOptimizeOption); + options.finalize = parser.isSet(finalizeOption); + options.printStats = parser.isSet(printStatsOption); + options.exportObjPath = parser.value(exportObjOption); + options.saveInstructionsPath = parser.value(saveInstructionsOption); + + if (options.inputPath.isEmpty()) + { + std::cerr << "--input is required in --cli mode." << std::endl; + return 1; + } + + return BrickrCli::run(options); + } + + QString startupFile = qEnvironmentVariable("BRICKR_OPEN_FILE"); + int startupResolution = qEnvironmentVariableIntValue("BRICKR_VOX_RES"); + + if (argc > 1 && argv[1] != NULL) + startupFile = QString::fromLocal8Bit(argv[1]); + + if (argc > 2 && argv[2] != NULL) + startupResolution = QString::fromLocal8Bit(argv[2]).toInt(); + + if (startupResolution <= 0) + startupResolution = 30; + GraphicsView view; view.setViewport(new QGLWidget(QGLFormat(QGL::SampleBuffers))); view.setViewportUpdateMode(QGraphicsView::FullViewportUpdate); - view.setScene(new OpenGLScene(1000,800)); + view.setScene(new OpenGLScene(1000,800, startupFile, startupResolution)); view.resize(1000, 800); view.show(); diff --git a/src/openglscene.cpp b/src/openglscene.cpp index dd2dfbf..970da32 100755 --- a/src/openglscene.cpp +++ b/src/openglscene.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #ifdef WIN32 #include @@ -35,7 +36,7 @@ QDialog *OpenGLScene::createDialog(const QString &windowTitle) const return dialog; } -OpenGLScene::OpenGLScene(int width, int height) +OpenGLScene::OpenGLScene(int width, int height, const QString &startupFile, int startupResolution) : m_backgroundColor(180, 225, 255) , m_distance(1.4f) { @@ -119,6 +120,13 @@ OpenGLScene::OpenGLScene(int width, int height) addItem(m_lightItem); resetScene(); + + if(!startupFile.isEmpty()) + { + QTimer::singleShot(0, m_assembly, [this, startupFile, startupResolution]() { + m_assembly->openFile(startupFile, startupResolution); + }); + } } OpenGLScene::~OpenGLScene() diff --git a/src/openglscene.h b/src/openglscene.h index 581a8c7..4317b72 100755 --- a/src/openglscene.h +++ b/src/openglscene.h @@ -27,7 +27,7 @@ class OpenGLScene : public QGraphicsScene Q_OBJECT public: - OpenGLScene(int width, int height); + OpenGLScene(int width, int height, const QString &startupFile = QString(), int startupResolution = 0); ~OpenGLScene(); void drawBackground(QPainter *painter, const QRectF &rect); diff --git a/tools/obj_to_binvox.py b/tools/obj_to_binvox.py new file mode 100644 index 0000000..04fd75c --- /dev/null +++ b/tools/obj_to_binvox.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +import argparse +import math +from pathlib import Path + +import numpy as np +import trimesh + + +def write_binvox(path: Path, occupancy: np.ndarray) -> None: + # Brickr reads dims as depth, height, width and maps the flattened stream as: + # level = i % height + # y = (i / height) % width + # x = i / (width * height) + # So the on-disk order must be [x][y][level] with level varying fastest. + depth, width, height = occupancy.shape + + data = occupancy.astype(np.uint8).reshape(-1) + encoded = bytearray() + + if data.size == 0: + raise ValueError("empty voxel grid") + + current = int(data[0]) + count = 0 + for value in data: + value = int(value) + if value == current and count < 255: + count += 1 + continue + + encoded.extend((current, count)) + current = value + count = 1 + + encoded.extend((current, count)) + + with path.open("wb") as f: + f.write(b"#binvox 1\n") + f.write(f"dim {depth} {height} {width}\n".encode("ascii")) + f.write(b"translate 0.0 0.0 0.0\n") + f.write(b"scale 1.0\n") + f.write(b"data\n") + f.write(encoded) + + +def voxelize(mesh_path: Path, output_path: Path, resolution: int) -> None: + mesh = trimesh.load(mesh_path, force="mesh") + if mesh.is_empty: + raise ValueError(f"failed to load mesh: {mesh_path}") + + bounds = mesh.bounds + extents = bounds[1] - bounds[0] + max_extent = float(np.max(extents)) + if max_extent <= 0: + raise ValueError("mesh has zero size") + + pitch = max_extent / float(resolution) + vox = mesh.voxelized(pitch=pitch) + + try: + vox = vox.fill() + except BaseException: + pass + + points = np.asarray(vox.points) + if points.size == 0: + raise ValueError("voxelization produced no filled cells") + + mins = points.min(axis=0) + scaled = np.rint((points - mins) / pitch).astype(int) + + dims_xyz = scaled.max(axis=0) + 1 + # Store as [x][y][level] so the flattened stream matches Brickr's parser. + grid = np.zeros((int(dims_xyz[0]), int(dims_xyz[2]), int(dims_xyz[1])), dtype=np.uint8) + + for x, y, z in scaled: + grid[x, z, y] = 1 + + write_binvox(output_path, grid) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Convert OBJ mesh to binvox for Brickr") + parser.add_argument("-d", "--dimension", type=int, required=True, help="voxel resolution") + parser.add_argument("input", help="input OBJ path") + parser.add_argument("output", help="output BINVOX path") + args = parser.parse_args() + + voxelize(Path(args.input), Path(args.output), args.dimension) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())