Support embedded LDraw texture data

Parse !DATA blocks as project-scoped assets and resolve TEXMAP textures
from immutable asset snapshots without sharing same-named embedded images
between external projects. Preserve original document blocks when saving,
including blank lines, payload formatting, and unchanged model text.

Carry required submodels and embedded assets through copy, paste, and
exports, validating conflicts before import. Return clipboard errors to
the main window and preserve selected objects when Cut cannot copy them.

Fixes #937
This commit is contained in:
Leonardo Zide committed 2026-10-08 21:49:14 -07:00
1 parent 202035d4ea
commit f39e1ae70a
13 files changed
+1066 -208

No files matched your search

+7 -2
View File
@@ -526,6 +526,9 @@ void lcAssetLoader::QueueTexture(lcTexture* Texture, Priority LoadPriority)
QString lcAssetLoader::TextureFailureMessage(const lcTexture* Texture)
{
if (!Texture->mLoadFailureDetails.isEmpty())
return Texture->mLoadFailureDetails;
switch (Texture->mLoadFailure)
{
case lcTextureLoadError::DecodeFailed:
@@ -589,6 +592,7 @@ void lcAssetLoader::CancelUnusedTextureRequests()
Texture->DiscardDecodedImage();
Texture->mState = lcTextureState::Unrequested;
Texture->mLoadFailure = lcTextureLoadError::None;
Texture->mLoadFailureDetails.clear();
mLibrary->ReleaseTexture(Texture);
}
@@ -979,6 +983,7 @@ bool lcAssetLoader::ProcessCompletions()
{
Texture->mState = lcTextureState::Failed;
Texture->mLoadFailure = Completed.TextureResult.Error;
Texture->mLoadFailureDetails = Completed.TextureResult.ErrorDetails;
FinishTextureRequest(RequestedTexture);
}
@@ -1054,7 +1059,7 @@ bool lcAssetLoader::ProcessCompletions()
if (Section.TextureName.isEmpty())
continue;
Section.Texture = mLibrary->FindTextureDeferred(Section.TextureName.toLatin1().constData(), {});
Section.Texture = mLibrary->FindTextureDeferred(Section.TextureName.toLatin1().constData(), std::vector<QString>(), std::map<QString, std::shared_ptr<const lcEmbeddedData>>());
if (!Section.Texture)
RequestedSynth->Error = tr("Missing texture %1.").arg(Section.TextureName);
@@ -1117,7 +1122,7 @@ bool lcAssetLoader::ProcessCompletions()
{
const auto TextureLookup = [this, &RequestedPart](const char* Name)
{
return mLibrary->FindTextureDeferred(Name, RequestedPart->Source.TextureSearchDirectories);
return mLibrary->FindTextureDeferred(Name, RequestedPart->Source.TextureSearchDirectories, RequestedPart->Source.EmbeddedData);
};
if (Completed.Result.CacheData && !Completed.Result.Mesh)
+3
View File
@@ -20,6 +20,7 @@ struct lcPartSourceSnapshot
QString LibraryDirectory;
QString CachePath;
std::vector<QString> TextureSearchDirectories;
std::map<QString, std::shared_ptr<const lcEmbeddedData>> EmbeddedData;
QStringList InlineMeshLines;
bool InlineModel = false;
bool SkipCache = false;
@@ -52,6 +53,7 @@ struct lcPartBuildResult
struct lcTextureSourceSnapshot
{
std::shared_ptr<const lcEmbeddedData> EmbeddedData;
QString FilePath;
int ArchiveType = -1;
int ArchiveIndex = -1;
@@ -68,6 +70,7 @@ struct lcTextureBuildResult
std::unique_ptr<Image> DecodedImage;
lcTextureLoadError Error{};
QString ErrorDetails;
};
// Owned by lcPiecesLibrary. UI-thread entry points schedule private worker builds;
+76 -6
View File
@@ -567,7 +567,12 @@ lcTexture* lcPiecesLibrary::FindTexture(const char* TextureName, Project* Curren
ProjectPath = QFileInfo(FileName).absolutePath();
}
lcTexture* Texture = FindTextureDeferred(TextureName, std::vector<QString>{ ProjectPath });
std::map<QString, std::shared_ptr<const lcEmbeddedData>> EmbeddedData;
if (CurrentProject)
EmbeddedData = CurrentProject->GetEmbeddedDataLookup();
lcTexture* Texture = FindTextureDeferred(TextureName, std::vector<QString>{ ProjectPath }, EmbeddedData);
if (!Texture)
return nullptr;
@@ -580,13 +585,14 @@ lcTexture* lcPiecesLibrary::FindTexture(const char* TextureName, Project* Curren
return nullptr;
}
lcTexture* lcPiecesLibrary::FindTextureDeferred(const char* TextureName, const std::vector<QString>& SearchDirectories)
lcTexture* lcPiecesLibrary::FindTextureDeferred(const char* TextureName, const std::vector<QString>& SearchDirectories, const std::map<QString, std::shared_ptr<const lcEmbeddedData>>& EmbeddedData)
{
if (!TextureName[0] || strlen(TextureName) >= LC_TEXTURE_NAME_LEN)
return nullptr;
QMutexLocker LoadLock(&mLoadMutex);
std::shared_ptr<const lcEmbeddedData> SelectedData;
QString FilePath;
QString ResolvedProjectPath;
int ArchiveType = -1;
@@ -598,6 +604,16 @@ lcTexture* lcPiecesLibrary::FindTextureDeferred(const char* TextureName, const s
// Complete the prefixed search before considering any unprefixed location.
for (const QString& Candidate : Candidates)
{
QString EmbeddedName = Candidate + QLatin1String(".png");
EmbeddedName.replace(QLatin1Char('\\'), QLatin1Char('/'));
const auto Embedded = EmbeddedData.find(QDir::cleanPath(EmbeddedName).toUpper());
if (Embedded != EmbeddedData.end())
{
SelectedData = Embedded->second;
break;
}
for (const QString& Directory : SearchDirectories)
{
if (Directory.isEmpty())
@@ -662,7 +678,7 @@ lcTexture* lcPiecesLibrary::FindTextureDeferred(const char* TextureName, const s
for (lcTexture* Texture : mTextures)
{
if (Texture->mFilePath != FilePath || Texture->mArchiveType != ArchiveType || Texture->mArchiveIndex != ArchiveIndex)
if (Texture->mEmbeddedData != SelectedData || Texture->mFilePath != FilePath || Texture->mArchiveType != ArchiveType || Texture->mArchiveIndex != ArchiveIndex)
continue;
Texture->AddRef();
@@ -670,17 +686,18 @@ lcTexture* lcPiecesLibrary::FindTextureDeferred(const char* TextureName, const s
return Texture;
}
if (FilePath.isEmpty() && ArchiveIndex == -1)
if (!SelectedData && FilePath.isEmpty() && ArchiveIndex == -1)
return nullptr;
lcTexture* Texture = new lcTexture(LC_TEXTURE_MIPMAPS);
lcstrcpy(Texture->mName, TextureName);
Texture->mEmbeddedData = SelectedData;
Texture->mFilePath = FilePath;
Texture->mProjectPath = ResolvedProjectPath;
Texture->mArchiveType = ArchiveType;
Texture->mArchiveIndex = ArchiveIndex;
Texture->SetTemporary(!ResolvedProjectPath.isEmpty());
Texture->SetTemporary(bool(SelectedData) || !ResolvedProjectPath.isEmpty());
Texture->AddRef();
mTextures.push_back(Texture);
@@ -692,6 +709,7 @@ lcTextureSourceSnapshot lcPiecesLibrary::SnapshotTextureSource(const lcTexture*
{
lcTextureSourceSnapshot Source;
Source.EmbeddedData = Texture->mEmbeddedData;
Source.FilePath = Texture->mFilePath;
Source.ArchiveType = Texture->mArchiveType;
Source.ArchiveIndex = Texture->mArchiveIndex;
@@ -699,6 +717,17 @@ lcTextureSourceSnapshot lcPiecesLibrary::SnapshotTextureSource(const lcTexture*
return Source;
}
lcResult<QByteArray> lcPiecesLibrary::DecodeEmbeddedData(const lcEmbeddedData& Data)
{
if (!Data.Error.isEmpty())
return lcUnexpected(Data.Error);
if (!Data.HasValidEncoding())
return lcUnexpected(tr("Invalid Base64 data for embedded texture '%1'.").arg(Data.FileName));
return QByteArray::fromBase64(Data.EncodedData);
}
lcTextureBuildResult lcPiecesLibrary::BuildTextureData(const lcTextureSourceSnapshot& Source)
{
lcTextureBuildResult Result;
@@ -707,7 +736,25 @@ lcTextureBuildResult lcPiecesLibrary::BuildTextureData(const lcTextureSourceSnap
bool Loaded = false;
if (!Source.FilePath.isEmpty())
if (Source.EmbeddedData)
{
const lcResult<QByteArray> Decoded = DecodeEmbeddedData(*Source.EmbeddedData);
if (Decoded)
{
lcMemFile TextureFile;
const QByteArray& Bytes = Decoded.value();
TextureFile.WriteBuffer(Bytes.constData(), Bytes.size());
TextureFile.Seek(0, SEEK_SET);
Loaded = Result.DecodedImage->FileLoad(TextureFile);
if (!Loaded)
Result.ErrorDetails = tr("Could not decode embedded texture '%1'.").arg(Source.EmbeddedData->FileName);
}
else
Result.ErrorDetails = Decoded.error();
}
else if (!Source.FilePath.isEmpty())
Loaded = Result.DecodedImage->FileLoad(Source.FilePath);
else if (Source.ArchiveType >= 0 && Source.ArchiveType < static_cast<int>(lcZipFileType::Count) && Source.ArchiveIndex >= 0)
{
@@ -1801,6 +1848,9 @@ lcPartSourceSnapshot lcPiecesLibrary::SnapshotPieceSource(const PieceInfo* Info)
const lcModel* Model = Info->GetModel();
Source.TextureSearchDirectories = Model->GetAssetSearchDirectories();
if (Model->GetProject())
Source.EmbeddedData = Model->GetProject()->GetEmbeddedDataLookup();
}
return Source;
@@ -2248,6 +2298,26 @@ void lcPiecesLibrary::UnloadUnusedParts()
bool lcPiecesLibrary::LoadTexture(lcTexture* Texture)
{
if (Texture->mEmbeddedData)
{
const lcResult<QByteArray> Decoded = DecodeEmbeddedData(*Texture->mEmbeddedData);
if (!Decoded)
{
Texture->mState = lcTextureState::Failed;
Texture->mLoadFailure = lcTextureLoadError::DecodeFailed;
Texture->mLoadFailureDetails = Decoded.error();
return false;
}
lcMemFile TextureFile;
const QByteArray& Bytes = Decoded.value();
TextureFile.WriteBuffer(Bytes.constData(), Bytes.size());
TextureFile.Seek(0, SEEK_SET);
return Texture->Load(TextureFile);
}
if (!Texture->mFilePath.isEmpty())
return Texture->Load(Texture->mFilePath);
+3 -1
View File
@@ -16,6 +16,7 @@ class lcThumbnailManager;
class lcAssetLoader;
struct lcPartSourceSnapshot;
struct lcPartBuildResult;
struct lcEmbeddedData;
struct lcTextureSourceSnapshot;
struct lcTextureBuildResult;
@@ -233,7 +234,7 @@ public:
// Release a returned reference with ReleaseTexture().
lcTexture* FindTexture(const char* TextureName, Project* CurrentProject, bool SearchProjectFolder);
// UI-thread lookup/acquisition only, without decoding or upload; caller releases the reference.
lcTexture* FindTextureDeferred(const char* TextureName, const std::vector<QString>& SearchDirectories);
lcTexture* FindTextureDeferred(const char* TextureName, const std::vector<QString>& SearchDirectories, const std::map<QString, std::shared_ptr<const lcEmbeddedData>>& EmbeddedData);
lcTextureSourceSnapshot SnapshotTextureSource(const lcTexture* Texture) const;
// Worker decode service: returns private pixels/errors without GL upload or publication.
lcTextureBuildResult BuildTextureData(const lcTextureSourceSnapshot& Source);
@@ -319,6 +320,7 @@ protected:
bool ReadDirectoryCacheFile(const QString& FileName, lcMemFile& CacheFile);
bool WriteDirectoryCacheFile(const QString& FileName, lcMemFile& CacheFile);
static lcResult<QByteArray> DecodeEmbeddedData(const lcEmbeddedData& Data);
static QString FindProjectTextureFile(const QString& ProjectPath, const QString& TextureName);
static bool IsStudPrimitive(const char* FileName);
static bool IsStudStylePrimitive(const char* FileName);
+4 -4
View File
@@ -2989,22 +2989,22 @@ void lcMainWindow::HandleCommand(lcCommandId CommandId)
case LC_EDIT_CUT:
if (ActiveModel)
ActiveModel->Cut();
ShowResultMessageBox(ActiveModel->Cut(), tr("Cannot Cut Model"));
break;
case LC_EDIT_COPY:
if (ActiveModel)
ActiveModel->Copy();
ShowResultMessageBox(ActiveModel->Copy(), tr("Cannot Copy Model"));
break;
case LC_EDIT_PASTE:
if (ActiveModel)
ActiveModel->Paste(true);
ShowResultMessageBox(ActiveModel->Paste(true), tr("Cannot Paste Model"));
break;
case LC_EDIT_PASTE_STEPS:
if (ActiveModel)
ActiveModel->Paste(false);
ShowResultMessageBox(ActiveModel->Paste(false), tr("Cannot Paste Model"));
break;
case LC_EDIT_FIND:
+89 -4
View File
@@ -1327,7 +1327,89 @@ bool lcMeshLoader::LoadMesh(lcFile& File, lcMeshDataType MeshDataType)
return ReadMeshData(File, lcMatrix44Identity(), 16, false, MeshDataType);
}
bool lcMeshLoader::ParseTextureName(const char* Text, char* Name, size_t NameSize)
std::vector<std::string> lcMeshLoader::GetTextureReferences(const QByteArray& FileData)
{
std::vector<std::string> Names;
const QList<QByteArray> Lines = FileData.split('\n');
for (int Index = 0; Index < Lines.size(); Index++)
{
QString Line = QString::fromUtf8(Lines[Index]).trimmed();
QTextStream Stream(&Line, QIODevice::ReadOnly);
QString LineType, Meta, Action, Projection;
Stream >> LineType >> Meta >> Action >> Projection;
if (LineType != QLatin1String("0") || Meta != QLatin1String("!TEXMAP") ||
(Action != QLatin1String("START") && Action != QLatin1String("NEXT")))
continue;
if (Action == QLatin1String("NEXT"))
{
int Next = Index + 1;
while (Next < Lines.size() && Lines[Next].trimmed().isEmpty())
Next++;
if (Next == Lines.size())
continue;
QString NextLine = QString::fromUtf8(Lines[Next]);
QTextStream NextStream(&NextLine, QIODevice::ReadOnly);
QString NextType;
NextStream >> NextType;
if (NextType == QLatin1String("0"))
continue;
}
int Parameters = 0;
if (Projection == QLatin1String("PLANAR"))
Parameters = 9;
else if (Projection == QLatin1String("CYLINDRICAL"))
Parameters = 10;
else if (Projection == QLatin1String("SPHERICAL"))
Parameters = 11;
else
continue;
bool Valid = true;
for (int Parameter = 0; Parameter < Parameters; Parameter++)
{
QString Token;
Stream >> Token;
bool Parsed;
Token.toFloat(&Parsed);
Valid &= Parsed;
}
if (!Valid)
continue;
const QByteArray Text = Stream.readAll().toLatin1();
char Name[LC_TEXTURE_NAME_LEN];
const char* End = nullptr;
if (!ParseTextureName(Text.constData(), Name, sizeof(Name), &End))
continue;
if (std::find(Names.begin(), Names.end(), Name) == Names.end())
Names.emplace_back(Name);
while (*End && *End <= 32)
End++;
if (!strncmp(End, "GLOSSMAP", 8) && (!End[8] || End[8] <= 32) &&
ParseTextureName(End + 8, Name, sizeof(Name), nullptr) && std::find(Names.begin(), Names.end(), Name) == Names.end())
Names.emplace_back(Name);
}
return Names;
}
bool lcMeshLoader::ParseTextureName(const char* Text, char* Name, size_t NameSize, const char** EndOfName)
{
while (*Text && *Text <= 32)
Text++;
@@ -1365,6 +1447,9 @@ bool lcMeshLoader::ParseTextureName(const char* Text, char* Name, size_t NameSiz
if (Length > 4 && !memcmp(Name + Length - 4, ".PNG", 4))
Name[Length - 4] = 0;
if (EndOfName)
*EndOfName = Text + (Quoted ? 1 : 0);
return true;
}
@@ -1465,7 +1550,7 @@ bool lcMeshLoader::ReadMeshData(lcFile& File, const lcMatrix44& CurrentTransform
int NameOffset = 0;
const int Parsed = sscanf(Token, "%f %f %f %f %f %f %f %f %f %n", &Points[0].x, &Points[0].y, &Points[0].z, &Points[1].x, &Points[1].y, &Points[1].z, &Points[2].x, &Points[2].y, &Points[2].z, &NameOffset);
if (Parsed != 9 || !ParseTextureName(Token + NameOffset, Map.Name, sizeof(Map.Name)))
if (Parsed != 9 || !ParseTextureName(Token + NameOffset, Map.Name, sizeof(Map.Name), nullptr))
{
mTextureStack.pop_back();
continue;
@@ -1490,7 +1575,7 @@ bool lcMeshLoader::ReadMeshData(lcFile& File, const lcMatrix44& CurrentTransform
int NameOffset = 0;
const int Parsed = sscanf(Token, "%f %f %f %f %f %f %f %f %f %f %n", &Points[0].x, &Points[0].y, &Points[0].z, &Points[1].x, &Points[1].y, &Points[1].z, &Points[2].x, &Points[2].y, &Points[2].z, &Angle, &NameOffset);
if (Parsed != 10 || !ParseTextureName(Token + NameOffset, Map.Name, sizeof(Map.Name)))
if (Parsed != 10 || !ParseTextureName(Token + NameOffset, Map.Name, sizeof(Map.Name), nullptr))
{
mTextureStack.pop_back();
continue;
@@ -1516,7 +1601,7 @@ bool lcMeshLoader::ReadMeshData(lcFile& File, const lcMatrix44& CurrentTransform
int NameOffset = 0;
const int Parsed = sscanf(Token, "%f %f %f %f %f %f %f %f %f %f %f %n", &Points[0].x, &Points[0].y, &Points[0].z, &Points[1].x, &Points[1].y, &Points[1].z, &Points[2].x, &Points[2].y, &Points[2].z, &Angle1, &Angle2, &NameOffset);
if (Parsed != 11 || !ParseTextureName(Token + NameOffset, Map.Name, sizeof(Map.Name)))
if (Parsed != 11 || !ParseTextureName(Token + NameOffset, Map.Name, sizeof(Map.Name), nullptr))
{
mTextureStack.pop_back();
continue;
+2 -1
View File
@@ -218,6 +218,7 @@ public:
lcMeshLoader(lcLibraryMeshData& MeshData, Project* CurrentProject, lcMeshLoaderFlags Flags);
bool LoadMesh(lcFile& File, lcMeshDataType MeshDataType);
static std::vector<std::string> GetTextureReferences(const QByteArray& FileData);
const QString& GetError() const { return mError; }
void SetCancellationFlag(const std::atomic_bool* Cancelled) { mCancelled = Cancelled; }
bool SearchProjectFolder() const { return mFlags.testFlag(lcMeshLoaderFlag::SearchProjectFolder); }
@@ -225,7 +226,7 @@ public:
Project* mCurrentProject;
protected:
static bool ParseTextureName(const char* Text, char* Name, size_t NameSize);
static bool ParseTextureName(const char* Text, char* Name, size_t NameSize, const char** EndOfName);
bool ReadMeshData(lcFile& File, const lcMatrix44& CurrentTransform, quint32 CurrentColorCode, bool InvertWinding, lcMeshDataType MeshDataType);
std::vector<lcMeshLoaderTextureMap> mTextureStack;
+62 -60
View File
@@ -624,9 +624,6 @@ void lcModel::SaveLDraw(QTextStream& Stream, bool SelectedOnly, lcStep LastStep)
if (SelectedOnly && !Piece->IsSelected())
continue;
if ((SavedStep = (LastStep != 0 && Piece->GetStepShow() > LastStep)))
break;
while (Piece->GetFileLine() > CurrentLine && CurrentLine < mFileLines.size())
{
QString Line = mFileLines[CurrentLine];
@@ -643,7 +640,15 @@ void lcModel::SaveLDraw(QTextStream& Stream, bool SelectedOnly, lcStep LastStep)
if (Token == QLatin1String("STEP"))
{
if (Piece->GetStepShow() > Step)
{
if (LastStep && Step >= LastStep)
{
SavedStep = true;
break;
}
Step++;
}
else
Skip = true;
}
@@ -658,6 +663,9 @@ void lcModel::SaveLDraw(QTextStream& Stream, bool SelectedOnly, lcStep LastStep)
CurrentLine++;
}
if (SavedStep || (LastStep && Piece->GetStepShow() > LastStep))
break;
while (Piece->GetStepShow() > Step)
{
Stream << QLatin1String("0 STEP\r\n");
@@ -750,8 +758,18 @@ void lcModel::SaveLDraw(QTextStream& Stream, bool SelectedOnly, lcStep LastStep)
{
LineStream >> Token;
if (Token == QLatin1String("STEP") && AddedSteps-- > 0)
Skip = true;
if (Token == QLatin1String("STEP"))
{
if (AddedSteps-- > 0)
Skip = true;
else
{
if (LastStep && Step >= LastStep)
break;
Step++;
}
}
}
if (!Skip)
@@ -776,45 +794,6 @@ void lcModel::SaveLDraw(QTextStream& Stream, bool SelectedOnly, lcStep LastStep)
Stream.flush();
}
int lcModel::SplitMPD(QIODevice& Device)
{
qint64 ModelPos = Device.pos();
while (!Device.atEnd())
{
const qint64 Pos = Device.pos();
QString OriginalLine = Device.readLine();
QString Line = OriginalLine.trimmed();
QTextStream LineStream(&Line, QIODevice::ReadOnly);
QString Token;
LineStream >> Token;
if (Token == QLatin1String("0"))
{
LineStream >> Token;
if (Token == QLatin1String("FILE"))
{
if (!mProperties.mFileName.isEmpty())
{
Device.seek(Pos);
break;
}
SetFileName(LineStream.readAll().trimmed());
ModelPos = Pos;
}
else if (Token == QLatin1String("NOFILE"))
{
break;
}
}
}
return ModelPos;
}
void lcModel::LoadLDraw(QIODevice& Device, Project* Project)
{
lcPiece* Piece = nullptr;
@@ -860,7 +839,7 @@ void lcModel::LoadLDraw(QIODevice& Device, Project* Project)
continue;
}
else if (Token == QLatin1String("NOFILE"))
else if (Token == QLatin1String("NOFILE") || Token == QLatin1String("!DATA"))
{
break;
}
@@ -1471,12 +1450,15 @@ void lcModel::Merge(std::unique_ptr<lcModel> Other)
gMainWindow->UpdateTimeline(false, false);
}
void lcModel::Cut()
lcResult<void> lcModel::Cut()
{
if (!AnyObjectsSelected())
return;
return lcResult<void>();
Copy();
const lcResult<void> Result = Copy();
if (!Result)
return Result;
BeginHistorySequence();
BeginEditHistory();
@@ -1488,28 +1470,46 @@ void lcModel::Cut()
gMainWindow->UpdateTimeline(false, false);
gMainWindow->UpdateSelectedObjects(true);
return lcResult<void>();
}
void lcModel::Copy()
lcResult<void> lcModel::Copy()
{
QByteArray File;
QTextStream Stream(&File, QIODevice::WriteOnly);
if (mProject)
{
const lcResult<QByteArray> File = mProject->SerializeModel(this, true, 0);
SaveLDraw(Stream, true, 0);
if (!File)
return lcUnexpected(File.error());
gApplication->ExportClipboard(File);
gApplication->ExportClipboard(File.value());
}
else
{
QByteArray File;
QTextStream Stream(&File, QIODevice::WriteOnly);
SaveLDraw(Stream, true, 0);
gApplication->ExportClipboard(File);
}
return lcResult<void>();
}
void lcModel::Paste(bool PasteToCurrentStep)
lcResult<void> lcModel::Paste(bool PasteToCurrentStep)
{
if (gApplication->mClipboard.isEmpty())
return;
return lcResult<void>();
std::unique_ptr<lcModel> Model(new lcModel(QString(), nullptr, false));
const lcResult<lcModel*> Imported = lcGetActiveProject()->ImportClipboard(gApplication->mClipboard);
QBuffer Buffer(&gApplication->mClipboard);
Buffer.open(QIODevice::ReadOnly);
Model->LoadLDraw(Buffer, lcGetActiveProject());
if (!Imported)
return lcUnexpected(Imported.error());
std::unique_ptr<lcModel> Model(Imported.value());
if (!Model)
return lcResult<void>();
const std::vector<std::unique_ptr<lcPiece>>& PastedPieces = Model->mPieces;
std::vector<lcObject*> SelectedObjects;
@@ -1532,7 +1532,7 @@ void lcModel::Paste(bool PasteToCurrentStep)
}
if (PastedPieces.empty())
return;
return lcResult<void>();
BeginHistorySequence();
BeginEditHistory();
@@ -1551,6 +1551,8 @@ void lcModel::Paste(bool PasteToCurrentStep)
EndHistorySequence(tr("Paste"));
gMainWindow->UpdateTimeline(false, false);
return lcResult<void>();
}
void lcModel::DuplicateSelectedPieces()
+3 -4
View File
@@ -309,15 +309,14 @@ public:
bool LoadBinary(lcFile* File);
bool LoadLDD(const QString& FileData);
bool LoadInventory(const std::vector<lcSetInventoryItem>& SetInventory);
int SplitMPD(QIODevice& Device);
void Merge(std::unique_ptr<lcModel> Other);
void SetMinifig(const lcMinifig& Minifig);
void SetPreviewPieceInfo(PieceInfo* Info, int ColorIndex);
void Cut();
void Copy();
void Paste(bool PasteToCurrentStep);
lcResult<void> Cut();
lcResult<void> Copy();
lcResult<void> Paste(bool PasteToCurrentStep);
void DuplicateSelectedPieces();
void PaintSelectedPieces();
void UpdateTrainTrackConnections(lcPiece* TrackPiece, bool IgnoreSelected) const;
+2
View File
@@ -242,6 +242,7 @@ void lcTexture::Upload(lcContext* Context)
mImages.clear();
mState = lcTextureState::Ready;
mLoadFailure = lcTextureLoadError::None;
mLoadFailureDetails.clear();
}
else
{
@@ -302,4 +303,5 @@ void lcTexture::Unload()
mTexture = 0;
mState = lcTextureState::Unrequested;
mLoadFailure = lcTextureLoadError::None;
mLoadFailureDetails.clear();
}
+5 -1
View File
@@ -37,6 +37,8 @@ enum class lcTextureLoadError
UploadFailed
};
struct lcEmbeddedData;
class lcTexture
{
public:
@@ -124,13 +126,15 @@ public:
int mWidth;
int mHeight;
char mName[LC_TEXTURE_NAME_LEN] = {};
QString mFilePath; // Absolute PNG path for disk-backed textures; empty for archive textures.
std::shared_ptr<const lcEmbeddedData> mEmbeddedData;
QString mFilePath; // Absolute PNG path for disk-backed textures; empty for archive or embedded textures.
QString mProjectPath; // Directory used to scope temporary textures to their project.
int mArchiveType = -1;
int mArchiveIndex = -1;
GLuint mTexture = 0;
lcTextureState mState = lcTextureState::Unrequested;
lcTextureLoadError mLoadFailure = lcTextureLoadError::None;
QString mLoadFailureDetails;
protected:
bool Load();
+762 -124
View File
@@ -12,6 +12,7 @@
#include "lc_mainwindow.h"
#include "lc_view.h"
#include "lc_library.h"
#include "lc_texture.h"
#include "lc_thumbnailmanager.h"
#include "lc_application.h"
#include "lc_profile.h"
@@ -686,6 +687,324 @@ void Project::SetFileName(const QString& FileName)
UpdateFileWatcher();
}
bool lcEmbeddedData::HasValidEncoding() const
{
const QByteArray& Encoded = EncodedData;
int Padding = 0;
bool Valid = !Encoded.isEmpty();
for (const unsigned char Character : Encoded)
{
if (Character == '=')
Padding++;
else if (Padding || !((Character >= 'A' && Character <= 'Z') || (Character >= 'a' && Character <= 'z') ||
(Character >= '0' && Character <= '9') || Character == '+' || Character == '/'))
{
Valid = false;
break;
}
}
return Valid && Padding <= 2 && Encoded.size() % 4 != 1 && (!Padding || Encoded.size() % 4 == 0);
}
QString Project::NormalizeEmbeddedDataName(const QString& FileName)
{
QString Name = FileName;
Name.replace(QLatin1Char('\\'), QLatin1Char('/'));
return QDir::cleanPath(Name).toUpper();
}
std::shared_ptr<const lcEmbeddedData> Project::FindEmbeddedData(const QString& FileName) const
{
if (FileName.isEmpty())
return std::shared_ptr<const lcEmbeddedData>();
const auto Entry = mEmbeddedDataIndex.find(NormalizeEmbeddedDataName(FileName));
if (Entry == mEmbeddedDataIndex.end())
return std::shared_ptr<const lcEmbeddedData>();
return Entry->second;
}
void Project::LoadLDrawDocument(const QByteArray& FileData, const Project* ReferenceProject, bool ReuseExistingModels)
{
enum class BlockType { ImplicitModel, Model, Data, Ignore };
struct ModelBlock
{
QString FileName;
qint64 Begin;
qint64 End;
size_t LayoutIndex;
};
std::vector<ModelBlock> ModelBlocks;
BlockType Type = BlockType::ImplicitModel;
qint64 BlockBegin = 0;
QString ModelName;
bool HasImplicitContent = false;
bool HasImplicitGeometry = false;
std::shared_ptr<lcEmbeddedData> Data;
const auto FinishBlock = [&](qint64 End, bool KeepImplicit)
{
DocumentBlock Block;
Block.Raw = FileData.mid(BlockBegin, End - BlockBegin);
if (Type == BlockType::Model)
{
ModelBlocks.push_back({ ModelName, BlockBegin, End, mDocumentBlocks.size() });
Block.ExplicitModel = true;
Block.OriginalName = ModelName;
}
else if (Type == BlockType::ImplicitModel && KeepImplicit && HasImplicitContent)
{
const QString Name = QFileInfo(mFileName).fileName();
ModelBlocks.push_back({ Name, BlockBegin, End, mDocumentBlocks.size() });
Block.OriginalName = Name;
}
else if (Type == BlockType::Data)
{
Data->RawBlock = Block.Raw;
if (Data->FileName.isEmpty())
Data->Error = tr("Embedded data has no filename.");
else
mEmbeddedDataIndex.emplace(NormalizeEmbeddedDataName(Data->FileName), Data);
Block.Data = Data;
mEmbeddedData.push_back(Data);
Data.reset();
}
mDocumentBlocks.push_back(std::move(Block));
};
QBuffer Buffer;
Buffer.setData(FileData);
Buffer.open(QIODevice::ReadOnly);
while (!Buffer.atEnd())
{
const qint64 Pos = Buffer.pos();
const QByteArray OriginalLine = Buffer.readLine();
QString Line = QString::fromUtf8(OriginalLine).trimmed();
QTextStream LineStream(&Line, QIODevice::ReadOnly);
QString LineType;
QString Meta;
LineStream >> LineType >> Meta;
if (LineType == QLatin1String("0") && Meta == QLatin1String("FILE"))
{
// Preamble before an explicit first FILE is not part of that model.
FinishBlock(Pos, false);
Type = BlockType::Model;
BlockBegin = Pos;
ModelName = LineStream.readAll().trimmed();
}
else if (LineType == QLatin1String("0") && Meta == QLatin1String("!DATA"))
{
// Also accept a plain LDR model followed by embedded data.
FinishBlock(Pos, HasImplicitGeometry);
Type = BlockType::Data;
BlockBegin = Pos;
Data = std::make_shared<lcEmbeddedData>();
Data->FileName = LineStream.readAll().trimmed();
}
else if (LineType == QLatin1String("0") && Meta == QLatin1String("NOFILE"))
{
FinishBlock(Pos, true);
Type = BlockType::Ignore;
BlockBegin = Pos;
}
else if (Type == BlockType::Data)
{
if (LineType == QLatin1String("0") && Meta == QLatin1String("!:"))
Data->EncodedData += LineStream.readAll().trimmed().toLatin1();
else if (!Line.isEmpty() && Data->Error.isEmpty())
Data->Error = tr("Unexpected statement in embedded data '%1'.").arg(Data->FileName);
}
else if (Type == BlockType::ImplicitModel && !Line.isEmpty())
{
HasImplicitContent = true;
int GeometryType = LineType.toInt();
if (LineType == QLatin1String("0") && Meta == QLatin1String("!:"))
{
QString Token;
LineStream >> Token;
GeometryType = Token.toInt();
}
HasImplicitGeometry |= GeometryType >= 1 && GeometryType <= 5;
}
}
FinishBlock(Buffer.pos(), true);
// Preserve the existing ability to open an empty ordinary LDraw document.
if (ModelBlocks.empty() && mEmbeddedData.empty())
ModelBlocks.push_back({ QFileInfo(mFileName).fileName(), 0, 0, 0 });
std::vector<std::pair<const ModelBlock*, lcModel*>> Models;
for (const ModelBlock& Block : ModelBlocks)
{
const auto Existing = std::find_if(mModels.begin(), mModels.end(), [&Block](const std::unique_ptr<lcModel>& Model)
{
return Model->GetFileName().compare(Block.FileName, Qt::CaseInsensitive) == 0;
});
if (Existing != mModels.end())
{
mDocumentBlocks[Block.LayoutIndex].Model = Existing->get();
mDocumentBlocks[Block.LayoutIndex].DuplicateModel = true;
continue;
}
if (ReuseExistingModels && ReferenceProject && !Models.empty())
{
const std::string Name = lcPiecesLibrary::NormalizePieceName(Block.FileName.toLatin1().constData());
PieceInfo* ExistingInfo = ReferenceProject->FindPiece(Name);
if (ExistingInfo && (ExistingInfo->IsModel() || ExistingInfo->IsProject()))
{
RegisterPiece(Name, ExistingInfo);
continue;
}
}
std::unique_ptr<lcModel> Model(new lcModel(Block.FileName, this, mIsPreview));
Models.emplace_back(&Block, Model.get());
mModels.emplace_back(std::move(Model));
mModels.back()->CreatePieceInfo(this);
mDocumentBlocks[Block.LayoutIndex].Model = mModels.back().get();
mDocumentBlocks[Block.LayoutIndex].OriginalName = Block.FileName;
}
// Legacy clipboard references can use destination pieces when no definition was copied.
if (ReferenceProject)
for (const auto& [Name, Info] : ReferenceProject->mPieceIndex)
if (!FindPiece(Name))
RegisterPiece(Name, Info);
// Every model and asset is registered before geometry resolves references.
for (const std::pair<const ModelBlock*, lcModel*>& Entry : Models)
{
QBuffer ModelBuffer;
ModelBuffer.setData(FileData.mid(Entry.first->Begin, Entry.first->End - Entry.first->Begin));
ModelBuffer.open(QIODevice::ReadOnly);
Entry.second->LoadLDraw(ModelBuffer, this);
Entry.second->SetSaved();
}
for (DocumentBlock& Block : mDocumentBlocks)
if (Block.Model)
Block.Baseline = SerializeModelText(Block.Model, false, 0);
}
QByteArray Project::SerializeModelText(const lcModel* Model, bool SelectedOnly, lcStep LastStep)
{
QByteArray Bytes;
QTextStream Stream(&Bytes, QIODevice::WriteOnly);
Model->SaveLDraw(Stream, SelectedOnly, LastStep);
Stream.flush();
return Bytes;
}
QByteArray Project::SerializeStoredModel(const lcModel* Model, const QString& Name, const DocumentBlock* Original, bool NeedsHeader, bool SelectedOnly, lcStep LastStep)
{
const QByteArray Current = SerializeModelText(Model, SelectedOnly, LastStep);
const bool Preserve = Original && !SelectedOnly && !LastStep && Current == Original->Baseline;
QByteArray LineEnding("\r\n");
if (Original && Original->Raw.contains('\n') && !Original->Raw.contains("\r\n"))
LineEnding = "\n";
if (Preserve && Original->ExplicitModel && Original->OriginalName == Name)
return Original->Raw;
QByteArray Text;
const bool Explicit = Original && Original->ExplicitModel;
const bool Renamed = Original && Original->OriginalName != Name;
if (NeedsHeader || Explicit || Renamed)
{
const int HeaderEnd = Original ? Original->Raw.indexOf('\n') : -1;
if (Explicit && !Renamed && HeaderEnd != -1)
Text = Original->Raw.left(HeaderEnd + 1);
else
Text = "0 FILE " + Name.toUtf8() + LineEnding;
}
if (Preserve)
{
if (Explicit)
{
const int HeaderEnd = Original->Raw.indexOf('\n');
Text += Original->Raw.mid(HeaderEnd == -1 ? Original->Raw.size() : HeaderEnd + 1);
}
else
Text += Original->Raw;
}
else
{
QByteArray Body = Current;
if (LineEnding == "\n")
Body.replace("\r\n", "\n");
if (Original && !Original->Raw.endsWith('\n') && !Original->Raw.endsWith('\r') && Body.endsWith(LineEnding))
Body.chop(LineEnding.size());
Text += Body;
}
return Text;
}
bool Project::WriteRaw(QTextStream& Stream, const QByteArray& Bytes)
{
Stream.flush();
if (Stream.status() != QTextStream::Ok)
return false;
if (QIODevice* Device = Stream.device())
return Device->write(Bytes) == Bytes.size();
Stream << QString::fromUtf8(Bytes);
return Stream.status() == QTextStream::Ok;
}
bool Project::SameEmbeddedData(const lcEmbeddedData& First, const lcEmbeddedData& Second)
{
if (First.Error != Second.Error)
return false;
if (First.EncodedData == Second.EncodedData)
return true;
if (!First.Error.isEmpty() || !First.HasValidEncoding() || !Second.HasValidEncoding())
return false;
int FirstSize = First.EncodedData.size();
int SecondSize = Second.EncodedData.size();
while (FirstSize && First.EncodedData[FirstSize - 1] == '=')
FirstSize--;
while (SecondSize && Second.EncodedData[SecondSize - 1] == '=')
SecondSize--;
return FirstSize == SecondSize && !memcmp(First.EncodedData.constData(), Second.EncodedData.constData(), FirstSize);
}
bool Project::Load(const QString& FileName, bool ShowErrors)
{
QWidget *parent = nullptr;
@@ -711,6 +1030,9 @@ bool Project::Load(const QString& FileName, bool ShowErrors)
}
mModels.clear();
mDocumentBlocks.clear();
mEmbeddedData.clear();
mEmbeddedDataIndex.clear();
lcGetPiecesLibrary()->ReleaseProjectPieces(this);
SetFileName(FileName);
QFileInfo FileInfo(FileName);
@@ -735,42 +1057,7 @@ bool Project::Load(const QString& FileName, bool ShowErrors)
if (LoadDAT)
{
QBuffer Buffer(&FileData);
Buffer.open(QIODevice::ReadOnly);
std::vector<std::pair<int, lcModel*>> Models;
while (!Buffer.atEnd())
{
lcModel* Model = new lcModel(QString(), this, mIsPreview);
int Pos = Model->SplitMPD(Buffer);
if (Models.empty() || !Model->GetFileName().isEmpty())
{
auto ModelCompare = [Model](const std::pair<int, lcModel*>& ModelIt)
{
return ModelIt.second->GetFileName().compare(Model->GetFileName(), Qt::CaseInsensitive) == 0;
};
if (std::find_if(Models.begin(), Models.end(), ModelCompare) == Models.end())
{
mModels.emplace_back(Model);
Models.emplace_back(std::make_pair(Pos, Model));
Model->CreatePieceInfo(this);
}
else
delete Model;
}
else
delete Model;
}
for (size_t ModelIdx = 0; ModelIdx < Models.size(); ModelIdx++)
{
Buffer.seek(Models[ModelIdx].first);
lcModel* Model = Models[ModelIdx].second;
Model->LoadLDraw(Buffer, this);
Model->SetSaved();
}
LoadLDrawDocument(FileData, nullptr, false);
}
else
{
@@ -975,25 +1262,172 @@ lcResult<void> Project::Save(const QString& FileName)
return lcResult<void>();
}
bool Project::Save(QTextStream& Stream)
bool Project::Save(QTextStream& Stream) const
{
bool MPD = mModels.size() > 1;
const bool MPD = mModels.size() > 1 || !mEmbeddedData.empty();
std::set<const lcModel*> WrittenModels;
std::set<const lcEmbeddedData*> WrittenData;
std::vector<const lcModel*> OriginalOrder;
const auto IsPresent = [this](const lcModel* Model)
{
return std::any_of(mModels.begin(), mModels.end(), [Model](const std::unique_ptr<lcModel>& Entry) { return Entry.get() == Model; });
};
for (const DocumentBlock& Block : mDocumentBlocks)
if (Block.Model && !Block.DuplicateModel && IsPresent(Block.Model))
OriginalOrder.push_back(Block.Model);
bool Reordered = false;
for (size_t Index = 0; Index < OriginalOrder.size(); Index++)
Reordered |= OriginalOrder[Index] != mModels[Index].get();
const auto OriginalFor = [this](const lcModel* Model) -> const DocumentBlock*
{
for (const DocumentBlock& Block : mDocumentBlocks)
if (Block.Model == Model && !Block.DuplicateModel)
return &Block;
return nullptr;
};
QByteArray Output;
const auto Append = [&Output](const QByteArray& Bytes, bool NewBlock)
{
if (NewBlock && !Output.isEmpty() && !Output.endsWith('\n') && !Output.endsWith('\r'))
Output += "\r\n";
Output += Bytes;
};
size_t NextModel = 0;
for (const DocumentBlock& Block : mDocumentBlocks)
{
if (Block.Model)
{
if (!IsPresent(Block.Model))
continue;
if (Block.DuplicateModel)
{
if (WrittenModels.find(Block.Model) != WrittenModels.end() && Block.Model->GetFileName().compare(Block.OriginalName, Qt::CaseInsensitive) == 0)
Append(Block.Raw, true);
continue;
}
const lcModel* Model = Reordered ? mModels[NextModel++].get() : Block.Model;
Append(SerializeStoredModel(Model, Model->GetFileName(), OriginalFor(Model), mModels.size() > 1, false, 0), true);
WrittenModels.insert(Model);
}
else if (Block.Data)
{
Append(Block.Raw, true);
WrittenData.insert(Block.Data.get());
}
else
Append(Block.Raw, false);
}
for (const std::unique_ptr<lcModel>& Model : mModels)
{
if (MPD)
Stream << QLatin1String("0 FILE ") << Model->GetProperties().mFileName << QLatin1String("\r\n");
if (WrittenModels.find(Model.get()) != WrittenModels.end())
continue;
Model->SaveLDraw(Stream, false, 0);
if (MPD)
Stream << QLatin1String("0 NOFILE\r\n");
Append(SerializeStoredModel(Model.get(), Model->GetFileName(), OriginalFor(Model.get()), MPD || !mDocumentBlocks.empty(), false, 0), true);
}
return true;
for (const std::shared_ptr<const lcEmbeddedData>& Data : mEmbeddedData)
if (WrittenData.find(Data.get()) == WrittenData.end())
Append(Data->RawBlock, true);
return WriteRaw(Stream, Output);
}
std::vector<QString> Project::GetMergedAssetDirectories(const lcModel* Model) const
{
if (Model->GetProject() == this)
return Model->GetAssetSearchDirectories();
lcModelAssetSourceState State = Model->GetAssetSourceState();
const QString SourceFile = Model->GetProject()->GetFileName();
const QString SourceDirectory = SourceFile.isEmpty() ? QString() : QFileInfo(SourceFile).absolutePath();
if (!SourceDirectory.isEmpty())
{
State.Directories.erase(std::remove(State.Directories.begin(), State.Directories.end(), SourceDirectory), State.Directories.end());
if (State.PreferCurrentDirectory)
State.Directories.insert(State.Directories.begin(), SourceDirectory);
else
State.Directories.push_back(SourceDirectory);
}
const QString DestinationDirectory = mFileName.isEmpty() ? QString() : QFileInfo(mFileName).absolutePath();
if (!DestinationDirectory.isEmpty() && std::find(State.Directories.begin(), State.Directories.end(), DestinationDirectory) == State.Directories.end())
State.Directories.push_back(DestinationDirectory);
return State.Directories;
}
lcResult<void> Project::ValidateMergedTextureBindings(const Project& Source, const std::map<QString, std::shared_ptr<const lcEmbeddedData>>& MergedData, const lcModel* ClipboardRoot) const
{
lcPiecesLibrary* Library = lcGetPiecesLibrary();
for (const std::unique_ptr<lcModel>& Model : Source.mModels)
{
if (Model.get() == ClipboardRoot)
continue;
std::vector<std::string> References = lcMeshLoader::GetTextureReferences(SerializeModelText(Model.get(), false, 0));
if (const lcMesh* Mesh = Model->GetPieceInfo()->GetMesh())
for (const lcMeshLod& Lod : Mesh->mLods)
for (int SectionIndex = 0; SectionIndex < Lod.NumSections; SectionIndex++)
if (!Lod.Sections[SectionIndex].TextureName.isEmpty())
References.push_back(Lod.Sections[SectionIndex].TextureName.toLatin1().constData());
const std::vector<QString> BeforeDirectories = Model->GetAssetSearchDirectories();
const std::vector<QString> AfterDirectories = GetMergedAssetDirectories(Model.get());
for (const std::string& Name : References)
{
lcTexture* Before = Library->FindTextureDeferred(Name.c_str(), BeforeDirectories, Source.mEmbeddedDataIndex);
if (!Before)
continue;
lcTexture* After = Library->FindTextureDeferred(Name.c_str(), AfterDirectories, MergedData);
bool Same = After != nullptr;
if (Same && Before->mEmbeddedData && After->mEmbeddedData)
Same = SameEmbeddedData(*Before->mEmbeddedData, *After->mEmbeddedData);
else if (Same)
Same = Before->mEmbeddedData == After->mEmbeddedData && Before->mFilePath == After->mFilePath &&
Before->mArchiveType == After->mArchiveType && Before->mArchiveIndex == After->mArchiveIndex;
Library->ReleaseTexture(Before);
if (After)
Library->ReleaseTexture(After);
if (!Same)
return lcUnexpected(tr("Merging would change texture '%1' used by model '%2'. Rename the conflicting texture before merging.").arg(QString::fromLatin1(Name.c_str()), Model->GetFileName()));
}
}
return lcResult<void>();
}
lcResult<void> Project::Merge(const std::vector<Project*>& Sources)
{
return MergeInternal(Sources, nullptr);
}
lcResult<void> Project::MergeInternal(const std::vector<Project*>& Sources, const lcModel* ClipboardRoot)
{
lcPiecesLibrary* Library = lcGetPiecesLibrary();
std::map<std::string, PieceInfo*> NonModelPieces;
@@ -1009,6 +1443,35 @@ lcResult<void> Project::Merge(const std::vector<Project*>& Sources)
};
std::vector<ModelPlan> PlannedModels;
std::map<QString, std::shared_ptr<const lcEmbeddedData>> MergedData = mEmbeddedDataIndex;
// Validate every incoming asset before reserving names or moving models.
for (const Project* Source : Sources)
{
for (const auto& [Name, Data] : Source->mEmbeddedDataIndex)
{
const auto Existing = MergedData.find(Name);
if (Existing != MergedData.end() && !SameEmbeddedData(*Existing->second, *Data))
return lcUnexpected(tr("Both projects contain different embedded data named '%1'. Rename one before merging.").arg(Data->FileName));
MergedData.emplace(Name, Data);
}
}
const lcResult<void> DestinationBindings = ValidateMergedTextureBindings(*this, MergedData, ClipboardRoot);
if (!DestinationBindings)
return DestinationBindings;
for (const Project* Source : Sources)
{
const lcResult<void> SourceBindings = ValidateMergedTextureBindings(*Source, MergedData, ClipboardRoot);
if (!SourceBindings)
return SourceBindings;
}
for (const std::map<std::string, PieceInfo*>::value_type& Entry : mPieceIndex)
{
@@ -1126,6 +1589,8 @@ lcResult<void> Project::Merge(const std::vector<Project*>& Sources)
AppliedRenames.push_back(&Planned);
}
mEmbeddedDataIndex = std::move(MergedData);
size_t PlanIndex = 0;
for (Project* Source : Sources)
@@ -1175,6 +1640,12 @@ lcResult<void> Project::Merge(const std::vector<Project*>& Sources)
Library->TransferProjectPieces(Source, this);
Source->mModels.clear();
mEmbeddedData.insert(mEmbeddedData.end(), Source->mEmbeddedData.begin(), Source->mEmbeddedData.end());
mDocumentBlocks.insert(mDocumentBlocks.end(), Source->mDocumentBlocks.begin(), Source->mDocumentBlocks.end());
Source->mEmbeddedData.clear();
Source->mEmbeddedDataIndex.clear();
Source->mDocumentBlocks.clear();
}
if (!Sources.empty())
@@ -1203,6 +1674,9 @@ bool Project::ImportLDD(const QString& FileName)
NewModel->SetSaved();
mModels.clear();
mDocumentBlocks.clear();
mEmbeddedData.clear();
mEmbeddedDataIndex.clear();
mModels.emplace_back(std::move(NewModel));
for (const std::unique_ptr<lcModel>& Model : mModels)
@@ -1233,6 +1707,9 @@ bool Project::ImportInventory(const std::vector<lcSetInventoryItem>& SetInventor
NewModel->SetDescription(Description);
mModels.clear();
mDocumentBlocks.clear();
mEmbeddedData.clear();
mEmbeddedDataIndex.clear();
mModels.emplace_back(std::move(NewModel));
for (const std::unique_ptr<lcModel>& Model : mModels)
@@ -1315,117 +1792,278 @@ lcResult<void> Project::EnsureAssetsReady() const
return Model ? Model->EnsureAssetsReady() : lcResult<void>();
}
lcResult<void> Project::ExportCurrentStep(const QString& FileName)
lcResult<QByteArray> Project::SerializeModel(const lcModel* Root, bool SelectedOnly, lcStep LastStep) const
{
QFile File(FileName);
if (!File.open(QIODevice::WriteOnly))
return lcUnexpected(tr("Error writing to file '%1':\n%2").arg(FileName, File.errorString()));
QStringList Models;
Models.append(lcGetActiveModel()->GetProperties().mFileName);
std::function<void(const QString&)> ParseStepModel = [&](const QString& ModelName)
struct ExportEntry
{
Models.append(ModelName);
for (const std::unique_ptr<lcModel>& Model : mModels)
{
if (Model->GetProperties().mFileName == ModelName)
{
lcPartsList ModelParts;
Model->GetPartsList(gDefaultColor, false, true, ModelParts);
for (const auto& PartIt : ModelParts)
{
const PieceInfo* PartInfo = PartIt.first;
if (PartInfo->IsModel())
{
ParseStepModel(PartInfo->mFileName);
}
else
continue;
}
break;
}
}
const lcModel* Model;
QString Name;
bool SelectedOnly;
};
const lcStep CurrentStep = lcGetActiveModel()->GetCurrentStep();
std::vector<ExportEntry> Models;
std::map<QString, const lcModel*> Names;
std::vector<std::shared_ptr<const lcEmbeddedData>> Assets;
std::map<QString, std::shared_ptr<const lcEmbeddedData>> AssetLookup;
bool MPD = mModels.size() > 1;
if (MPD)
std::function<lcResult<void>(const lcModel*, const QString&, bool)> Collect = [&](const lcModel* Model, const QString& Name, bool FilterSelection) -> lcResult<void>
{
lcPartsList StepParts;
const QString Key = NormalizeEmbeddedDataName(Name);
const auto Existing = Names.find(Key);
lcGetActiveModel()->GetPartsListForStep(CurrentStep, gDefaultColor, StepParts, true);
if (!StepParts.empty())
if (Existing != Names.end())
{
for (const auto& PartIt : StepParts)
{
const PieceInfo *PartInfo = PartIt.first;
if (Existing->second != Model)
return lcUnexpected(tr("Cannot export different submodels named '%1'.").arg(Name));
if (PartInfo->IsModel())
{
ParseStepModel(PartInfo->mFileName);
}
else
return lcResult<void>();
}
Names.emplace(Key, Model);
Models.push_back({ Model, Name, FilterSelection });
for (const std::unique_ptr<lcPiece>& Piece : Model->GetPieces())
{
if ((FilterSelection && !Piece->IsSelected()) || (LastStep && Piece->GetStepShow() > LastStep))
continue;
const PieceInfo* Info = Piece->mPieceInfo;
const lcModel* Child = nullptr;
if (Info && Info->IsModel())
Child = Info->GetModel();
else if (Info && Info->IsProject())
Child = Info->GetProject()->GetMainModel();
if (Child)
{
const lcResult<void> Result = Collect(Child, Piece->GetID(), false);
if (!Result)
return Result;
}
}
return lcResult<void>();
};
const lcResult<void> Collected = Collect(Root, Root->GetFileName(), SelectedOnly);
if (!Collected)
return lcUnexpected(Collected.error());
// A complete model export can retain the original document layout too.
bool WholeProject = !SelectedOnly && !LastStep && Root == GetMainModel() && Models.size() == mModels.size();
for (const ExportEntry& Entry : Models)
WholeProject &= Entry.Model->GetProject() == this && NormalizeEmbeddedDataName(Entry.Name) == NormalizeEmbeddedDataName(Entry.Model->GetFileName());
if (WholeProject)
{
QByteArray Bytes;
QTextStream Stream(&Bytes, QIODevice::WriteOnly);
if (!Save(Stream))
return lcUnexpected(tr("Could not serialize all model data."));
Stream.flush();
return Bytes;
}
std::vector<const Project*> Owners;
std::map<const Project*, std::set<QString>> RequiredNames;
for (const ExportEntry& Entry : Models)
{
const Project* Owner = Entry.Model->GetProject();
if (!Owner)
continue;
if (RequiredNames.find(Owner) == RequiredNames.end())
Owners.push_back(Owner);
std::set<QString>& Required = RequiredNames[Owner];
const std::vector<std::string> References = lcMeshLoader::GetTextureReferences(SerializeModelText(Entry.Model, Entry.SelectedOnly, LastStep));
for (const std::string& Name : References)
{
const QString Bare = QString::fromLatin1(Name.c_str()) + QLatin1String(".png");
const QString Candidates[] = { QLatin1String("textures/") + Bare, Bare };
for (const QString& Candidate : Candidates)
{
const std::shared_ptr<const lcEmbeddedData> Data = Owner->FindEmbeddedData(Candidate);
if (!Data)
continue;
const QString Key = NormalizeEmbeddedDataName(Data->FileName);
const auto Existing = AssetLookup.find(Key);
if (Existing != AssetLookup.end() && !SameEmbeddedData(*Existing->second, *Data))
return lcUnexpected(tr("Cannot export different embedded data named '%1'.").arg(Data->FileName));
AssetLookup.emplace(Key, Data);
Required.insert(Key);
}
}
}
QTextStream Stream(&File);
for (const Project* Owner : Owners)
for (const std::shared_ptr<const lcEmbeddedData>& Data : Owner->mEmbeddedData)
if (RequiredNames[Owner].find(NormalizeEmbeddedDataName(Data->FileName)) != RequiredNames[Owner].end())
Assets.push_back(Data);
for (const std::unique_ptr<lcModel>& Model : mModels)
const bool MPD = Models.size() > 1 || !Assets.empty();
QByteArray Output;
const auto Append = [&Output](const QByteArray& Bytes)
{
if (!Models.contains(Model->GetProperties().mFileName))
continue;
if (!Output.isEmpty() && !Output.endsWith('\n') && !Output.endsWith('\r'))
Output += "\r\n";
const lcStep ModelStep = Model->GetCurrentStep();
Output += Bytes;
};
if (!Model->IsActive())
Model->SetTemporaryStep(CurrentStep);
for (const ExportEntry& Entry : Models)
{
const DocumentBlock* Original = nullptr;
const Project* Owner = Entry.Model->GetProject();
if (MPD)
Stream << QLatin1String("0 FILE ") << Model->GetProperties().mFileName << QLatin1String("\r\n");
if (Owner)
for (const DocumentBlock& Block : Owner->mDocumentBlocks)
if (Block.Model == Entry.Model && !Block.DuplicateModel)
{
Original = &Block;
break;
}
Model->SaveLDraw(Stream, false, CurrentStep);
if (MPD)
Stream << QLatin1String("0 NOFILE\r\n");
if (!Model->IsActive())
{
Model->SetTemporaryStep(ModelStep);
Model->CalculateStep(LC_STEP_MAX);
}
Append(SerializeStoredModel(Entry.Model, Entry.Name, Original, MPD, Entry.SelectedOnly, LastStep));
}
File.close();
for (const std::shared_ptr<const lcEmbeddedData>& Data : Assets)
Append(Data->RawBlock);
if (SelectedOnly)
Output.prepend("0 !LEOCAD CLIPBOARD PROJECT " + mClipboardIdentity.toString().toUtf8() + "\r\n");
return Output;
}
lcResult<lcModel*> Project::ImportClipboard(const QByteArray& FileData)
{
Project Source(mIsPreview);
Source.mModels.clear();
lcGetPiecesLibrary()->ReleaseProjectPieces(&Source);
Source.SetDeferModelMeshRequests(true);
Source.mFileName = mFileName;
const QByteArray Marker("0 !LEOCAD CLIPBOARD PROJECT ");
const QByteArray FirstLine = FileData.left(FileData.indexOf('\n') == -1 ? FileData.size() : FileData.indexOf('\n')).trimmed();
const bool SameProject = FirstLine.startsWith(Marker) && QUuid(QString::fromUtf8(FirstLine.mid(Marker.size()))) == mClipboardIdentity;
Source.LoadLDrawDocument(FileData, this, SameProject);
if (Source.mModels.empty())
return lcUnexpected(tr("Clipboard contains no model."));
lcModel* Root = Source.GetMainModel();
for (const std::unique_ptr<lcModel>& Model : Source.mModels)
Model->QueueMeshBuild();
if (Root->GetPieces().empty())
return static_cast<lcModel*>(nullptr);
if (SameProject)
{
std::set<const lcModel*> Reachable;
std::function<void(const lcModel*)> Collect = [&](const lcModel* Model)
{
if (!Reachable.insert(Model).second)
return;
for (const std::unique_ptr<lcPiece>& Piece : Model->GetPieces())
if (Piece->mPieceInfo->IsModel() && Piece->mPieceInfo->GetModel()->GetProject() == &Source)
Collect(Piece->mPieceInfo->GetModel());
};
Collect(Root);
Source.mModels.erase(std::remove_if(Source.mModels.begin(), Source.mModels.end(), [&Reachable](const std::unique_ptr<lcModel>& Model)
{
return Reachable.find(Model.get()) == Reachable.end();
}), Source.mModels.end());
// Live reused models already own their current assets. Keep snapshots only
// for definitions that were missing and therefore still need importing.
std::set<QString> Required;
for (const std::unique_ptr<lcModel>& Model : Source.mModels)
{
if (Model.get() == Root)
continue;
for (const std::string& Name : lcMeshLoader::GetTextureReferences(SerializeModelText(Model.get(), false, 0)))
{
const QString Bare = QString::fromLatin1(Name.c_str()) + QLatin1String(".png");
Required.insert(NormalizeEmbeddedDataName(Bare));
Required.insert(NormalizeEmbeddedDataName(QLatin1String("textures/") + Bare));
}
}
Source.mEmbeddedData.erase(std::remove_if(Source.mEmbeddedData.begin(), Source.mEmbeddedData.end(), [&Required](const std::shared_ptr<const lcEmbeddedData>& Data)
{
return Required.find(NormalizeEmbeddedDataName(Data->FileName)) == Required.end();
}), Source.mEmbeddedData.end());
for (auto Entry = Source.mEmbeddedDataIndex.begin(); Entry != Source.mEmbeddedDataIndex.end();)
if (Required.find(Entry->first) == Required.end())
Entry = Source.mEmbeddedDataIndex.erase(Entry);
else
++Entry;
}
// Clipboard formatting is generated; only retain the actual assets and submodels.
Source.mDocumentBlocks.clear();
const lcResult<void> Result = MergeInternal({ &Source }, Root);
if (!Result)
return lcUnexpected(Result.error());
const auto Entry = std::find_if(mModels.begin(), mModels.end(), [Root](const std::unique_ptr<lcModel>& Model) { return Model.get() == Root; });
std::unique_ptr<lcModel> Wrapper = std::move(*Entry);
mModels.erase(Entry);
return Wrapper.release();
}
lcResult<void> Project::ExportCurrentStep(const QString& FileName)
{
const lcModel* Model = lcGetActiveModel();
const lcResult<QByteArray> Bytes = SerializeModel(Model, false, Model->GetCurrentStep());
if (!Bytes)
return lcUnexpected(Bytes.error());
QSaveFile File(FileName);
if (!File.open(QIODevice::WriteOnly) || File.write(Bytes.value()) != Bytes.value().size() || !File.commit())
return lcUnexpected(tr("Error writing to file '%1':\n%2").arg(FileName, File.errorString()));
lcSetProfileString(LC_PROFILE_PROJECTS_PATH, QFileInfo(FileName).absolutePath());
return lcResult<void>();
}
lcResult<void> Project::ExportModel(const QString& FileName, lcModel* Model) const
{
QFile File(FileName);
const lcResult<QByteArray> Bytes = SerializeModel(Model, false, 0);
if (!File.open(QIODevice::WriteOnly))
if (!Bytes)
return lcUnexpected(Bytes.error());
QSaveFile File(FileName);
if (!File.open(QIODevice::WriteOnly) || File.write(Bytes.value()) != Bytes.value().size() || !File.commit())
return lcUnexpected(tr("Error writing to file '%1':\n%2").arg(FileName, File.errorString()));
QTextStream Stream(&File);
Model->SaveLDraw(Stream, false, 0);
return lcResult<void>();
}
+48 -1
View File
@@ -44,6 +44,15 @@ struct lcSetInventoryItem
int ColorCode;
};
struct lcEmbeddedData
{
bool HasValidEncoding() const;
QString FileName;
QByteArray EncodedData;
QByteArray RawBlock;
QString Error;
};
class Project
{
public:
@@ -60,6 +69,17 @@ public:
return mModels;
}
const std::vector<std::shared_ptr<const lcEmbeddedData>>& GetEmbeddedData() const
{
return mEmbeddedData;
}
const std::map<QString, std::shared_ptr<const lcEmbeddedData>>& GetEmbeddedDataLookup() const
{
return mEmbeddedDataIndex;
}
std::shared_ptr<const lcEmbeddedData> FindEmbeddedData(const QString& FileName) const;
lcModel* GetModel(const QString& FileName) const;
PieceInfo* FindPiece(const std::string& Name) const;
void RegisterPiece(const std::string& Name, PieceInfo* Info);
@@ -116,7 +136,7 @@ public:
bool Load(const QString& FileName, bool ShowErrors);
lcResult<void> Save(const QString& FileName);
bool Save(QTextStream& Stream);
bool Save(QTextStream& Stream) const;
lcResult<void> Merge(const std::vector<Project*>& Sources);
bool ImportLDD(const QString& FileName);
bool ImportInventory(const std::vector<lcSetInventoryItem>& SetInventory, const QString& Name, const QString& Description);
@@ -126,6 +146,9 @@ public:
std::vector<lcPiece*> GetRequiredSynthPieces() const;
lcResult<void> EnsureAssetsReady() const;
lcResult<void> ExportCurrentStep(const QString& FileName);
lcResult<QByteArray> SerializeModel(const lcModel* Model, bool SelectedOnly, lcStep LastStep) const;
// Caller owns the returned model; imported dependencies remain in this project.
lcResult<lcModel*> ImportClipboard(const QByteArray& FileData);
lcResult<void> ExportModel(const QString& FileName, lcModel* Model) const;
lcResult<void> Export3DStudio(const QString& FileName);
lcResult<void> ExportBrickLink();
@@ -144,6 +167,25 @@ private:
bool Exists = false;
bool Readable = false;
};
struct DocumentBlock
{
lcModel* Model = nullptr;
std::shared_ptr<const lcEmbeddedData> Data;
QByteArray Raw;
QByteArray Baseline;
QString OriginalName;
bool ExplicitModel = false;
bool DuplicateModel = false;
};
static QByteArray SerializeModelText(const lcModel* Model, bool SelectedOnly, lcStep LastStep);
static QByteArray SerializeStoredModel(const lcModel* Model, const QString& Name, const DocumentBlock* Original, bool NeedsHeader, bool SelectedOnly, lcStep LastStep);
static bool WriteRaw(QTextStream& Stream, const QByteArray& Bytes);
static bool SameEmbeddedData(const lcEmbeddedData& First, const lcEmbeddedData& Second);
static QString NormalizeEmbeddedDataName(const QString& FileName);
void LoadLDrawDocument(const QByteArray& FileData, const Project* ReferenceProject, bool ReuseExistingModels);
std::vector<QString> GetMergedAssetDirectories(const lcModel* Model) const;
lcResult<void> ValidateMergedTextureBindings(const Project& Source, const std::map<QString, std::shared_ptr<const lcEmbeddedData>>& MergedData, const lcModel* ClipboardRoot) const;
lcResult<void> MergeInternal(const std::vector<Project*>& Sources, const lcModel* ClipboardRoot);
static FileState ReadFileState(const QString& FileName);
bool HasFileChanged(const QString& Path);
static QString MakeExportNameFragment(const QString& Name);
@@ -156,6 +198,7 @@ protected:
std::vector<lcModelPartsEntry> GetModelParts();
void SetFileName(const QString& FileName);
QUuid mClipboardIdentity = QUuid::createUuid();
bool mIsPreview;
// External projects can parse their models before queuing direct meshes.
bool mDeferModelMeshRequests = false;
@@ -168,6 +211,10 @@ protected:
QFileSystemWatcher mFileWatcher;
QTimer mFileCheckTimer;
std::vector<DocumentBlock> mDocumentBlocks;
std::vector<std::shared_ptr<const lcEmbeddedData>> mEmbeddedData;
// First definition wins; retain duplicate blocks in mEmbeddedData for saving.
std::map<QString, std::shared_ptr<const lcEmbeddedData>> mEmbeddedDataIndex;
std::vector<std::unique_ptr<lcModel>> mModels;
// Maps normalized local filenames to PieceInfo objects visible in this project.
// This map does not own them; lcPiecesLibrary tracks and deletes them.