diff --git a/implementations/Crafter.Build-Asset.cpp b/implementations/Crafter.Build-Asset.cpp index 7bd227a..9b36c12 100644 --- a/implementations/Crafter.Build-Asset.cpp +++ b/implementations/Crafter.Build-Asset.cpp @@ -29,9 +29,15 @@ namespace fs = std::filesystem; namespace Crafter { std::string CompressAsset(const fs::path& input, const fs::path& output) { std::string ext = input.extension().string(); + // Case-insensitive extension match — Sponza's textures dir mixes + // case (.tga and .TGA both appear in the wild). + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); #ifdef CRAFTER_BUILD_HAS_ASSET try { - if (ext == ".png") { + // stb_image (the loader behind LoadPNG) handles all of these, + // so a single branch covers every raster format we ship. The + // function is misnamed for historical reasons. + if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { auto tex = TextureAsset>::LoadPNG(input); tex.SaveCompressed(output); } else if (ext == ".obj") { @@ -50,6 +56,65 @@ namespace Crafter { "rebuild via `./bin/crafter-build` so the self-host pass picks up the " "Crafter.Asset dep declared in project.cpp", input.string()); +#endif + } + + std::string BuildOBJBundle( + const fs::path& objPath, + const fs::path& mtlPath, + const fs::path& outDir, + std::uint16_t albedoSize) + { +#ifdef CRAFTER_BUILD_HAS_ASSET + const fs::path manifest = outDir / "scene.txt"; + if (fs::exists(manifest)) return {}; + try { + fs::create_directories(outDir); + auto materials = LoadMTL(mtlPath); + auto meshes = MeshAsset::LoadOBJSplit(objPath); + + std::vector uniqueAlbedos; + std::unordered_map albedoIndex; + for (auto& [matName, _] : meshes) { + auto matIt = materials.find(matName); + if (matIt == materials.end() || matIt->second.mapKd.empty()) continue; + const std::string& kd = matIt->second.mapKd; + if (!albedoIndex.contains(kd)) { + albedoIndex.emplace(kd, static_cast(uniqueAlbedos.size())); + uniqueAlbedos.push_back(kd); + } + } + + std::vector meshAlbedoIdx; + std::uint32_t emitted = 0; + for (auto& [matName, mesh] : meshes) { + if (mesh.vertexes.empty() || mesh.indexes.empty()) continue; + mesh.SaveCompressed(outDir / std::format("mesh_{}.cmesh", emitted)); + std::int32_t a = -1; + if (auto it = materials.find(matName); + it != materials.end() && !it->second.mapKd.empty()) + a = static_cast(albedoIndex.at(it->second.mapKd)); + meshAlbedoIdx.push_back(a); + ++emitted; + } + + for (std::uint32_t i = 0; i < uniqueAlbedos.size(); ++i) { + const fs::path texPath = mtlPath.parent_path() / uniqueAlbedos[i]; + auto tex = TextureAsset>::LoadPNG(texPath); + tex.Resize(albedoSize, albedoSize); + tex.SaveCompressed(outDir / std::format("tex_{}.ctex", i)); + } + + std::ofstream m(manifest); + m << uniqueAlbedos.size() << "\n" << emitted << "\n"; + for (std::int32_t a : meshAlbedoIdx) m << a << "\n"; + } catch (const std::exception& e) { + return std::format("BuildOBJBundle: {}", e.what()); + } + return {}; +#else + return "BuildOBJBundle: crafter-build was bootstrapped without Crafter.Asset linkage; " + "rebuild via crafter-build itself to pick up the Crafter.Asset dep"; #endif } } diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index edff5a0..12b0534 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -17,6 +17,15 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) +#include +#include +#else +#include +#include +#include +#endif export module Crafter.Build:Clang_impl; import std; import :Clang; @@ -304,7 +313,12 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map std::optional { std::string ext = src.extension().string(); - if (ext == ".png") return fs::path(src.filename()).replace_extension(".ctex"); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + // stb_image (used by CompressAsset → TextureAsset::LoadPNG) handles + // png/tga/jpg/bmp; all map to .ctex. + if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { + return fs::path(src.filename()).replace_extension(".ctex"); + } if (ext == ".obj") return fs::path(src.filename()).replace_extension(".cmesh"); return std::nullopt; }; @@ -341,7 +355,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map outName = compressedName(asset); if (!outName) { buildCancelled.store(true); - buildError = std::format("{}: unsupported asset extension (expected .png or .obj, or a directory)", asset.string()); + buildError = std::format("{}: unsupported asset extension (expected .png/.tga/.jpg/.bmp/.obj, or a directory)", asset.string()); break; } submitCompress(asset, *outName); @@ -752,10 +766,15 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map(std::tolower(static_cast(c))); fs::path srcName = asset.filename(); - if (ext == ".png") srcName.replace_extension(".ctex"); - else if (ext == ".obj") srcName.replace_extension(".cmesh"); - else continue; + if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { + srcName.replace_extension(".ctex"); + } else if (ext == ".obj") { + srcName.replace_extension(".cmesh"); + } else { + continue; + } fs::path src = depBinDir / srcName; if (!fs::exists(src)) continue; copyTree(src, outputDir / srcName); @@ -1080,7 +1099,22 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) { // runtime.js into an in-memory VFS so the wasm's std::ifstream et al. // can actually read them (the wasi-runtime in this repo otherwise // stubs every fd syscall to zero). The manifest lists basenames only; - // each file lives next to the .wasm in the bin dir. + // each file lives next to the .wasm in the bin dir. wasi-runtime + // reduces every path_open path to its basename, so subdir layouts + // collapse — that's fine for our flat-bin convention. + auto compressedBasename = [](const fs::path& src) -> std::optional { + std::string ext = src.extension().string(); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + fs::path out = src.filename(); + if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { + out.replace_extension(".ctex"); + } else if (ext == ".obj") { + out.replace_extension(".cmesh"); + } else { + return std::nullopt; + } + return out.string(); + }; std::vector assetFiles; seen.clear(); std::function walkAssets = [&](Configuration* c) { @@ -1095,6 +1129,31 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) { assetFiles.push_back(std::move(name)); } } + // cfg.assets — emit the *compressed* output basename. Directory + // entries get the same compressed-basename treatment per file; + // unrecognized extensions are treated as passthrough (kept under + // their original name, matching the build-side passthrough copy). + for (const fs::path& a : c->assets) { + if (fs::is_directory(a)) { + std::error_code ec; + for (const auto& entry : fs::recursive_directory_iterator(a, ec)) { + if (ec) break; + if (!entry.is_regular_file()) continue; + std::string name = compressedBasename(entry.path()) + .value_or(entry.path().filename().string()); + if (name.empty()) continue; + if (std::find(assetFiles.begin(), assetFiles.end(), name) == assetFiles.end()) { + assetFiles.push_back(std::move(name)); + } + } + } else { + std::optional name = compressedBasename(a); + if (!name) continue; + if (std::find(assetFiles.begin(), assetFiles.end(), *name) == assetFiles.end()) { + assetFiles.push_back(std::move(*name)); + } + } + } for (Configuration* dep : c->dependencies) walkAssets(dep); }; walkAssets(&cfg); @@ -1339,10 +1398,35 @@ int Crafter::Run(int argc, char** argv) { return std::system(probe.c_str()) == 0; }; if (browserBuild) { - // Try installed HTTP servers in priority order: lightweight - // / dependency-free first, ad-hoc ones last. Foreground - // (Ctrl-C to stop); we exec, not fork. - const int port = 8080; + // Probe-bind to find a free port starting at 8080 — if the + // user has another dev server running we shift up rather + // than letting caddy/python exit with EADDRINUSE. + auto findFreePort = [](int basePort, int span) -> int { +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) + static const bool wsaInit = []{ WSADATA d; return WSAStartup(MAKEWORD(2, 2), &d) == 0; }(); + if (!wsaInit) return basePort; + using sock_t = SOCKET; + const sock_t invalid = INVALID_SOCKET; + auto closesock = [](sock_t s){ closesocket(s); }; +#else + using sock_t = int; + const sock_t invalid = -1; + auto closesock = [](sock_t s){ ::close(s); }; +#endif + for (int p = basePort; p < basePort + span; ++p) { + sock_t s = ::socket(AF_INET, SOCK_STREAM, 0); + if (s == invalid) return basePort; + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(static_cast(p)); + bool ok = ::bind(s, reinterpret_cast(&addr), sizeof(addr)) == 0; + closesock(s); + if (ok) return p; + } + return basePort; + }; + const int port = findFreePort(8080, 16); std::string cmd; std::string_view picked; if (have("caddy")) { diff --git a/implementations/Crafter.Build-External.cpp b/implementations/Crafter.Build-External.cpp index 92b850d..e9d1689 100644 --- a/implementations/Crafter.Build-External.cpp +++ b/implementations/Crafter.Build-External.cpp @@ -396,6 +396,34 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) { return ptr; } +fs::path Crafter::GitFetch(const GitSource& source) { + std::string name = DeriveName(source); + if (name.empty()) { + throw std::runtime_error(std::format( + "GitFetch: could not derive name from URL '{}'", source.url)); + } + fs::path externalRoot = GetCacheDir() / "external"; + std::error_code ec; + fs::create_directories(externalRoot, ec); + if (ec) { + throw std::runtime_error(std::format( + "GitFetch: failed to create {}: {}", externalRoot.string(), ec.message())); + } + std::string keyMaterial = std::format("git|{}|{}|{}", + source.url, source.branch, source.commit); + std::size_t key = std::hash{}(keyMaterial); + fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key); + + { + bool exists = fs::exists(cloneDir); + Progress::Task task(std::format("{} {}", exists ? "Updating" : "Cloning", name)); + if (std::string err = FetchGit(source, cloneDir); !err.empty()) { + throw std::runtime_error(std::format("GitFetch({}): {}", source.url, err)); + } + } + return cloneDir; +} + Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) { fs::path projectPath = fs::absolute(spec.projectFile).lexically_normal(); if (!fs::exists(projectPath)) { diff --git a/interfaces/Crafter.Build-Asset.cppm b/interfaces/Crafter.Build-Asset.cppm index 8bb4cbe..7545401 100644 --- a/interfaces/Crafter.Build-Asset.cppm +++ b/interfaces/Crafter.Build-Asset.cppm @@ -36,4 +36,26 @@ namespace Crafter { // support requires a self-host rebuild via crafter-build itself, // which resolves the Crafter.Asset dep through project.cpp. export CRAFTER_API std::string CompressAsset(const fs::path& input, const fs::path& output); + + // Split a single .obj + .mtl file by material group into per-material + // .cmesh files, per-unique-albedo .ctex files, and a scene.txt manifest + // under outDir. All albedo textures are resized to albedoSize × albedoSize + // before compression (required for a texture_2d_array where all layers + // must have identical dimensions). + // + // scene.txt format (read by the Sponza example at runtime): + // + // + // (-1 = no albedo) + // ... + // + // Idempotent: returns "" immediately if outDir/scene.txt already exists, + // so repeated builds skip the expensive split+resize step. + // Returns "" on success, an error string on failure. + export CRAFTER_API std::string BuildOBJBundle( + const fs::path& objPath, + const fs::path& mtlPath, + const fs::path& outDir, + std::uint16_t albedoSize = 1024 + ); } diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index 7c8966b..275b963 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -97,4 +97,14 @@ export namespace Crafter { // recursively LoadProject's it. Returns a stable Configuration* owned // by the same internal cache as GitProject. CRAFTER_API Configuration* LocalProject(const LocalProjectSpec& spec); + + // Clone (or update) the given git source into the shared external + // cache and return the path to the cloned working tree. Intended for + // example projects that need large asset bundles which shouldn't + // live in the parent repo — push the returned path (or specific + // files inside it) into `cfg.assets`. Reuses the same cache root as + // ExternalDependency / GitProject. Caching key = url + branch + commit; + // identical calls reuse the existing clone. Pin `source.commit` to a + // SHA for reproducible, offline-after-first-fetch builds. + CRAFTER_API fs::path GitFetch(const GitSource& source); } diff --git a/project.cpp b/project.cpp index 00deb51..a057c5a 100644 --- a/project.cpp +++ b/project.cpp @@ -104,6 +104,10 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.linkFlags.push_back("-Wl,--export-dynamic"); cfg.linkFlags.push_back("-ldl"); } + if (cfg.target == "x86_64-w64-mingw32" || cfg.target == "x86_64-pc-windows-msvc") { + // winsock for the -r wasm port probe (bind/WSAStartup). + crafterBuildLib->linkFlags.push_back("-lws2_32"); + } return cfg; }