From 4c02a19132e408d2bacfc755cfe6496cdad9cade Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Thu, 18 Dec 2025 21:41:12 +0100 Subject: [PATCH] feat: migrate build system from Makefile to CMake - Replace cpp/build.py with CMake-based build system - Remove obsolete cpp/Makefile (CMake handles compilation now) - Update GitHub workflows to set EMSDK_PATH environment variable - Build now uses emcmake cmake to configure LAMMPS with Emscripten - Supports configurable packages via PACKAGES env var (default: MOLECULE) - Supports SINGLE_FILE mode to embed WASM in JS (default: enabled) - Supports debug builds with --debug flag - Supports force recompilation with --recompile flag This modernizes the build system to match the approach used in atomify-main, providing better dependency management and cleaner build orchestration. --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + cpp/Makefile | 45 ---- cpp/build.py | 404 ++++++++++++++++++++++++---------- package-lock.json | 4 +- 5 files changed, 293 insertions(+), 162 deletions(-) delete mode 100644 cpp/Makefile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6080b0..8f8e426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,4 +41,5 @@ jobs: - name: Build & test run: | source "$HOME/emsdk/emsdk_env.sh" + export EMSDK_PATH="$HOME/emsdk" npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b949e2b..4257824 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,7 @@ jobs: - name: Build & test run: | source "$HOME/emsdk/emsdk_env.sh" + export EMSDK_PATH="$HOME/emsdk" npm test - name: Publish package diff --git a/cpp/Makefile b/cpp/Makefile deleted file mode 100644 index d50220c..0000000 --- a/cpp/Makefile +++ /dev/null @@ -1,45 +0,0 @@ -CXX = emcc - -LAMMPS_SOURCE := $(wildcard lammps/src/*.cpp) -LAMMPS_SOURCE := $(filter-out lammps/src/main.cpp, $(LAMMPS_SOURCE)) -LAMMPS_SOURCE += lammps/src/STUBS/mpi.cpp -LAMMPS_OBJ_FILES := $(addprefix obj/,$(notdir $(LAMMPS_SOURCE:.cpp=.o))) - -#LD_FLAGS := -O1 --pre-js locateFile.js --no-entry -gsource-map --source-map-base="http://localhost:3000/atomify/" -lembind -#CC_FLAGS := -O0 -DLAMMPS_EXCEPTIONS -DLAMMPS_SMALLSMALL -gsource-map # -DNDEBUG -LD_FLAGS := -Oz --pre-js locateFile.js --no-entry -lembind -CC_FLAGS := -Oz -DLAMMPS_EXCEPTIONS -s NO_DISABLE_EXCEPTION_CATCHING=1 -DCOLVARS_LAMMPS -INCLUDE_FLAGS := -Ilammps/src -Ilammps/src/STUBS -SYMBOLS := \ - -s ENVIRONMENT='web,worker,node' \ - -s ALLOW_MEMORY_GROWTH=1 \ - -s ASYNCIFY \ - -s MODULARIZE=1 \ - -s EXPORT_ES6=1 \ - -s EXPORT_NAME='createModule' \ - -s EXPORTED_RUNTIME_METHODS="['getValue','FS', 'HEAPF32', 'HEAP32', 'HEAPF64', 'HEAP64']" \ - -s FORCE_FILESYSTEM=1 \ - -s SINGLE_FILE=1 \ - -s ASSERTIONS=2 \ - -s INITIAL_MEMORY=2048MB \ - -s TOTAL_STACK=1024MB \ - -s SINGLE_FILE=1 \ - -s DISABLE_EXCEPTION_CATCHING=0 \ - -default: wasm -wasm: obj lammps.js - -lammps.js: $(LAMMPS_OBJ_FILES) - $(CXX) $(SYMBOLS) $(LD_FLAGS) -o $@ $^ - -obj: - mkdir -p obj - -obj/%.o: lammps/src/%.cpp - $(CXX) $(CC_FLAGS) $(INCLUDE_FLAGS) -c -o $@ $< - -obj/%.o: lammps/src/STUBS/%.cpp - $(CXX) $(CC_FLAGS) $(INCLUDE_FLAGS) -c -o $@ $< - -clean: - rm lammps.js; rm obj/* diff --git a/cpp/build.py b/cpp/build.py index 2ad9b95..942ac87 100644 --- a/cpp/build.py +++ b/cpp/build.py @@ -1,24 +1,115 @@ #!/usr/bin/env python3 +""" +CMake-based build script for lammps.js + +This script builds LAMMPS as a WebAssembly module using CMake and Emscripten. +It handles cloning LAMMPS, configuring CMake, building the library, and linking +the final WASM module. + +Usage: + python cpp/build.py # Basic build + python cpp/build.py --debug # Debug build with source maps + python cpp/build.py -r # Force full recompilation + +Environment variables: + EMSDK_PATH - Path to Emscripten SDK (required) + LAMMPS_TAG - Git tag/branch for LAMMPS (default: patch_10Sep2025) + PACKAGES - Space-separated list of LAMMPS packages (default: MOLECULE) + SINGLE_FILE - Set to "0" to output separate .wasm file (default: "1") +""" + import os -import shutil import subprocess +import shutil +import sys from pathlib import Path +# Configuration LAMMPS_TAG = os.environ.get("LAMMPS_TAG", "patch_10Sep2025") +PACKAGES = os.environ.get("PACKAGES", "MOLECULE").split() +SINGLE_FILE = os.environ.get("SINGLE_FILE", "1") == "1" + BASE_DIR = Path(__file__).resolve().parent LAMMPS_DIR = BASE_DIR / "lammps" SRC_DIR = LAMMPS_DIR / "src" +BUILD_DIR = BASE_DIR / "build_emscripten" + +# Custom source files to copy into LAMMPS +CUSTOM_BASENAMES = ["lammpsweb"] + -# Override with: PACKAGES="yes-molecule yes-kspace" -PACKAGES = os.environ.get("PACKAGES", "yes-molecule").split() +def get_emsdk_path(): + """Get and validate EMSDK path.""" + emsdk_path = os.environ.get("EMSDK_PATH") + if not emsdk_path: + sys.exit("ERROR: The EMSDK_PATH environment variable must be set to your Emscripten SDK path.") + + emsdk_env = Path(emsdk_path) / "emsdk_env.sh" + if not emsdk_env.exists(): + sys.exit(f"ERROR: Emscripten SDK not found at {emsdk_path}") + + return str(emsdk_env) -# Files you actually need from your local code -CUSTOM_BASENAMES = [ - "lammpsweb", -] -LOCATE_FILE = BASE_DIR / "locateFile.js" -LOCATE_FILE_STUB = """\ +def file_content(path: Path) -> str: + """Read file content or return empty string if file doesn't exist.""" + if not path.exists(): + return "" + return path.read_text() + + +def copy_if_changed(src: Path, dst: Path) -> None: + """Copy file only if content has changed.""" + if file_content(src) != file_content(dst): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) + print(f"Copied: {src.name} -> {dst.relative_to(BASE_DIR)}") + + +def ensure_clone() -> None: + """Clone LAMMPS if not already present.""" + if LAMMPS_DIR.is_dir(): + print(f"Using existing LAMMPS clone at {LAMMPS_DIR}") + return + + print(f"Cloning LAMMPS ({LAMMPS_TAG})...") + subprocess.check_call([ + "git", "clone", + "--depth", "1", + "--branch", LAMMPS_TAG, + "https://github.com/lammps/lammps.git", + str(LAMMPS_DIR), + ], cwd=BASE_DIR) + + +def copy_custom_sources() -> None: + """Copy custom lammpsweb sources to LAMMPS src directory.""" + for basename in CUSTOM_BASENAMES: + for ext in (".cpp", ".h"): + src = BASE_DIR / "lammpsweb" / f"{basename}{ext}" + dst = SRC_DIR / f"{basename}{ext}" + if src.exists(): + copy_if_changed(src, dst) + + +def remove_broken_imd() -> None: + """Remove fix_imd which doesn't work in WebAssembly.""" + # Check in both root src and MISC subdirectory + for subdir in ["", "MISC"]: + for ext in [".cpp", ".h"]: + target = SRC_DIR / subdir / f"fix_imd{ext}" + if target.is_file(): + target.unlink() + print(f"Removed: {target.relative_to(BASE_DIR)}") + + +def ensure_locate_file() -> None: + """Create locateFile.js if it doesn't exist.""" + locate_file = BASE_DIR / "locateFile.js" + if locate_file.exists(): + return + + locate_file.write_text("""\ if (typeof Module === "undefined") { Module = {}; } @@ -27,120 +118,203 @@ return path; }; } -""" +""") + print(f"Created: {locate_file.relative_to(BASE_DIR)}") -def read(path: Path) -> str: - return path.read_text() if path.exists() else "" +def configure_cmake(emsdk_env: str, debug_mode: bool = False) -> None: + """Configure CMake with Emscripten and required packages.""" + print("Configuring CMake with Emscripten...") + + # CMake source path (LAMMPS has its cmake config in cmake/) + cmake_source = "../lammps/cmake" + cmake_source_abs = LAMMPS_DIR / "cmake" + if not cmake_source_abs.exists(): + sys.exit(f"ERROR: CMake source directory not found: {cmake_source_abs}") + + # Build package flags + package_flags = [f"-DPKG_{pkg}=ON" for pkg in PACKAGES] + print(f"Enabling packages: {', '.join(PACKAGES)}") + + # Compiler flags + cc_flags_common = "-DLAMMPS_EXCEPTIONS -s NO_DISABLE_EXCEPTION_CATCHING=1" + + if debug_mode: + cc_flags = f"-O0 -gsource-map {cc_flags_common}" + build_type = "Debug" + else: + cc_flags = f"-Oz -DNDEBUG -flto {cc_flags_common}" + build_type = "Release" + + cmake_args = [ + "emcmake", "cmake", + cmake_source, + f"-DCMAKE_BUILD_TYPE={build_type}", + "-DCMAKE_CXX_STANDARD=17", + "-DCMAKE_CXX_STANDARD_REQUIRED=ON", + "-DLAMMPS_SIZES=smallbig", + "-DBUILD_MPI=OFF", # Use LAMMPS built-in MPI STUBS for serial build + f'-DCMAKE_CXX_FLAGS="{cc_flags}"', + f'-DCMAKE_C_FLAGS="{cc_flags}"', + ] + package_flags + + # Create build directory + BUILD_DIR.mkdir(parents=True, exist_ok=True) + + # Source emsdk_env.sh and run cmake + cmake_cmd = f'source {emsdk_env} && cd {BUILD_DIR} && {" ".join(cmake_args)}' + subprocess.run(cmake_cmd, shell=True, executable="/bin/bash", check=True) + print("CMake configuration complete!") -def copy_if_changed(src: Path, dst: Path) -> None: - if read(src) != read(dst): - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(src, dst) - print(f"updated: {dst.relative_to(BASE_DIR)}") +def build_lammps_library(emsdk_env: str) -> None: + """Build the LAMMPS library using CMake.""" + print("Building LAMMPS library...") + + jobs = os.cpu_count() or 1 + build_cmd = f'source {emsdk_env} && cd {BUILD_DIR} && cmake --build . --target lammps -j{jobs}' + + subprocess.run(build_cmd, shell=True, executable="/bin/bash", check=True) + print("LAMMPS library build complete!") -def ensure_clone() -> None: - if LAMMPS_DIR.is_dir(): - return - print("Cloning LAMMPS ...") - subprocess.check_call( - [ - "git", - "clone", - "--depth", - "1", - "--branch", - LAMMPS_TAG, - "https://github.com/lammps/lammps.git", - str(LAMMPS_DIR), - ], - cwd=BASE_DIR, - ) - -def install_packages(): - if not PACKAGES: - return - cmd = ["make"] + PACKAGES - print("Installing packages:", " ".join(PACKAGES)) - subprocess.check_call(" ".join(cmd), shell=True, cwd=str(SRC_DIR)) - -def copy_custom_sources(): - for base in CUSTOM_BASENAMES: - for ext in (".cpp", ".h"): - src = BASE_DIR / "lammpsweb" / f"{base}{ext}" - dst = SRC_DIR / f"{base}{ext}" - if src.exists(): - copy_if_changed(src, dst) - -def remove_broken_imd(): - target_cpp = SRC_DIR / "fix_imd.cpp" - target_h = SRC_DIR / "fix_imd.h" - if target_cpp.is_file(): - target_cpp.unlink() - if target_h.is_file(): - target_h.unlink() - print("removed: fix_imd.*") - - - -def refresh_metadata(): - print("Generating LAMMPS style and package headers ...") - subprocess.check_call(['sh', 'Make.sh', 'style'], cwd=str(SRC_DIR)) - subprocess.check_call(['sh', 'Make.sh', 'packages'], cwd=str(SRC_DIR)) - subprocess.check_call(['make', 'lmpinstalledpkgs.h'], cwd=str(SRC_DIR)) - subprocess.check_call(['make', 'gitversion'], cwd=str(SRC_DIR)) - - -def build_native_once(): - print("Native prebuild skipped (not required for wasm build) ...") - -def build_wasm(): - env = os.environ.copy() - if env.get("SINGLE_FILE") == "1": - # Request embedded wasm; emscripten picks this from CFLAGS in most Makefile flows - extra = env.get("EMCC_CFLAGS", "") - flags = extra.split() + ["-s", "SINGLE_FILE=1", "-s", "MODULARIZE=1", "-s", "EXPORT_ES6=1"] - env["EMCC_CFLAGS"] = " ".join(flags) - print("SINGLE_FILE=1 enabled for emscripten build") - print("Building wasm/JS ...") - subprocess.check_call("make -j8", shell=True, cwd=str(SRC_DIR), env=env) - -def ensure_emcc(): - if shutil.which("emcc") is None: - raise RuntimeError( - "Emscripten compiler 'emcc' not found on PATH. Activate emsdk before running build.py." - ) - -def ensure_locate_file(): - if LOCATE_FILE.exists(): - return - LOCATE_FILE.write_text(LOCATE_FILE_STUB) - print(f"created: {LOCATE_FILE.relative_to(BASE_DIR)}") - -def build_bundle(): - ensure_emcc() - ensure_locate_file() - env = os.environ.copy() - cache_dir = env.setdefault("EM_CACHE", str(BASE_DIR / ".emscripten_cache")) - Path(cache_dir).mkdir(parents=True, exist_ok=True) - print("Linking lammps.js via top-level Makefile ...") - subprocess.check_call("make wasm", shell=True, cwd=str(BASE_DIR), env=env) - if not (BASE_DIR / "lammps.js").exists(): - raise RuntimeError("Emscripten link step completed but did not produce lammps.js") +def link_wasm_module(emsdk_env: str, debug_mode: bool = False) -> None: + """Link the LAMMPS library into a WASM module.""" + print("Linking WASM module...") + + # Find the library file + lib_path = BUILD_DIR / "liblammps.a" + if not lib_path.exists(): + lib_path = BUILD_DIR / "lib" / "liblammps.a" + if not lib_path.exists(): + sys.exit(f"ERROR: LAMMPS library not found") + + lib_abs_path = lib_path.resolve() + locate_file_abs = (BASE_DIR / "locateFile.js").resolve() + + # Output file + output_file = BASE_DIR / "lammps.js" + + # Build emcc arguments + emcc_args = [] + + if debug_mode: + emcc_args.extend(["-O1", "-gsource-map", "--source-map-base=http://localhost:5173/"]) + else: + emcc_args.extend(["-Oz", "-flto"]) + + # Pre-js and basic flags + emcc_args.extend([ + "--pre-js", str(locate_file_abs), + "--no-entry", + "-lembind", + ]) + + # Environment - support web, worker, and node + emcc_args.extend(["-s", "ENVIRONMENT=web,worker,node"]) + + # Exception handling + emcc_args.extend(["-s", "NO_DISABLE_EXCEPTION_CATCHING=1"]) + + # Memory settings + emcc_args.extend([ + "-s", "ALLOW_MEMORY_GROWTH=1", + "-s", "INITIAL_MEMORY=256MB", + ]) + + # Async support + emcc_args.extend(["-s", "ASYNCIFY"]) + + # Module settings + emcc_args.extend([ + "-s", "MODULARIZE=1", + "-s", "EXPORT_ES6=1", + "-s", "EXPORT_NAME='createModule'", + ]) + + # Runtime exports + emcc_args.extend([ + "-s", "EXPORTED_RUNTIME_METHODS=['getValue','FS','HEAP32','HEAPF32','HEAPF64','HEAP64']", + ]) + + # Filesystem + emcc_args.extend(["-s", "FORCE_FILESYSTEM=1"]) + + # Single file mode (embed wasm in JS) + if SINGLE_FILE: + emcc_args.extend(["-s", "SINGLE_FILE=1"]) + print("Building with embedded WASM (SINGLE_FILE=1)") + else: + print("Building with separate WASM file") + + # Assertions for debugging + if debug_mode: + emcc_args.extend(["-s", "ASSERTIONS=2"]) + + # Library linking - use whole-archive to include all symbols + emcc_args.extend([ + "-Wl,--whole-archive", + str(lib_abs_path), + "-Wl,--no-whole-archive", + ]) + + # Output file + emcc_args.extend(["-o", str(output_file)]) + + # Build the command with proper quoting + def quote_arg(arg): + if any(c in arg for c in [" ", "'", "[", "]"]): + return f'"{arg}"' + return arg + + emcc_cmd = "emcc " + " ".join(quote_arg(arg) for arg in emcc_args) + full_cmd = f'source {emsdk_env} && {emcc_cmd}' + + subprocess.run(full_cmd, shell=True, executable="/bin/bash", check=True) + print(f"WASM module linked: {output_file.relative_to(BASE_DIR.parent)}") + def main(): - ensure_clone() - install_packages() - copy_custom_sources() - remove_broken_imd() - build_native_once() - refresh_metadata() - build_wasm() - build_bundle() + # Parse arguments + debug_mode = "--debug" in sys.argv or "-d" in sys.argv + recompile = "--recompile" in sys.argv or "-r" in sys.argv + + if debug_mode: + print("Building in DEBUG mode with source maps...") + else: + print("Building in RELEASE mode (optimized)...") + + # Setup + emsdk_env = get_emsdk_path() + ensure_locate_file() + + # Clone LAMMPS if needed + ensure_clone() + + # Copy custom sources + print("Copying custom source files...") + copy_custom_sources() + + # Remove problematic files + remove_broken_imd() + + # Clean build directory if recompile requested + if recompile and BUILD_DIR.exists(): + print("Cleaning build directory for full recompilation...") + shutil.rmtree(BUILD_DIR) + + # Configure CMake + configure_cmake(emsdk_env, debug_mode=debug_mode) + + # Build library + build_lammps_library(emsdk_env) + + # Link WASM module + link_wasm_module(emsdk_env, debug_mode=debug_mode) + + print("\nBuild complete!") + print(f"Output: {BASE_DIR / 'lammps.js'}") - print("\nDone.\nArtifacts are left in:", SRC_DIR) if __name__ == "__main__": - main() + main() diff --git a/package-lock.json b/package-lock.json index dc15247..e26d0c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lammps.js", - "version": "0.1.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lammps.js", - "version": "0.1.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@types/node": "^20.11.30",