Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Pack an OpenSCAD entry file and all its `use`/`include` dependencies into a sing

Instead of distributing a project as a directory tree of files (and requiring recipients to have the same libraries installed), OpenSCAD Packer bundles everything into one file. It uses **tree-shaking** to include only the functions and modules that are actually called — directly or transitively — so the output stays lean even when pulling from large libraries.

## Why?

3D printing model sites such as [Printables](https://www.printables.com), [Thingiverse](https://www.thingiverse.com), and [MakerWorld](https://makerworld.com) support **parametric models**: the site parses an uploaded `.scad` file, exposes its configurable parameters to the user (via OpenSCAD's [Customizer](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Customizer)), and renders a download-ready 3MF or STL with the chosen values — no local OpenSCAD installation required.

The catch: **these sites only accept a single `.scad` file**. A design that pulls in [BOSL2](https://github.com/BelfrySCAD/BOSL2), [NopSCADlib](https://github.com/nophead/NopSCADlib), or any other library cannot be uploaded as-is. The creator must somehow inline all dependencies into one file before uploading.

OpenSCAD Packer automates that. It resolves every `use` and `include`, tree-shakes out the unused code, and writes a single self-contained file ready to upload.

OpenSCAD's Customizer reads **magic comments** in the `.scad` source to build its parameter UI — for example, `width = 20; // [1:100]` creates a slider, and `/* [Section Name] */` creates a collapsible group. By default, OpenSCAD Packer preserves comments from the entry file so these annotations survive packing and the Customizer UI works as expected on the destination site. Pass `--no-preserve-comments` to strip them if you prefer a leaner output.

## How it works

- `use <lib.scad>` — the library is parsed and only the reachable function and module definitions are inlined. Variables and top-level calls are discarded (matching OpenSCAD's own `use` semantics).
Expand Down Expand Up @@ -38,8 +48,10 @@ Arguments:
INPUT Entry .scad file to pack.

Options:
-o, --output PATH Output file. Defaults to stdout.
-L, --library-path PATH Extra library search directory (repeatable).
-o, --output PATH Output file. Defaults to stdout.
-L, --library-path PATH Extra library search directory (repeatable).
--preserve-comments/--no-preserve-comments
Preserve entry-file comments in output (default: on).
--help
```

Expand Down
14 changes: 12 additions & 2 deletions openscad_packer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,20 @@ def cli() -> None:
type=click.Path(exists=True, file_okay=False, readable=True),
help="Extra library search path (may be repeated).",
)
def pack(input: str, output: str | None, library_paths: tuple[str, ...]) -> None:
@click.option(
"--preserve-comments/--no-preserve-comments",
default=True,
help="Preserve comments from the entry file in packed output (default: on).",
)
def pack(
input: str,
output: str | None,
library_paths: tuple[str, ...],
preserve_comments: bool,
) -> None:
"""Pack INPUT into a single self-contained OpenSCAD file."""
try:
result = Packer(input, list(library_paths)).pack()
result = Packer(input, list(library_paths), preserve_comments=preserve_comments).pack()
except PackerError as exc:
raise click.ClickException(str(exc)) from exc

Expand Down
181 changes: 177 additions & 4 deletions openscad_packer/packer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@

from __future__ import annotations

import contextlib
import io
import os
import sys

from openscad_parser.ast import getASTfromFile
from openscad_parser.ast import getASTfromFile, getASTfromString
from openscad_parser.ast.nodes import (
ASTNode,
Assignment,
CommentLine,
CommentSpan,
FunctionDeclaration,
IncludeStatement,
ModuleDeclaration,
Expand All @@ -31,16 +37,144 @@
from .shaker import collect_called_names, compute_reachable


def _strip_expression_comments(code: str) -> str:
"""Strip `//` line comments that appear inside `()` or `[]` expression contexts.

Statement-level comments (including OpenSCAD Customizer magic annotations like
`// [min:max]` and section headers like `/* [Name] */`) are left untouched.
`/* */` block comments are always preserved regardless of nesting depth.
"""
result: list[str] = []
i = 0
depth = 0 # nesting inside ( and [ only; { is a scope, not an expression

while i < len(code):
c = code[i]

# String literal — skip its contents verbatim
if c == '"':
result.append(c)
i += 1
while i < len(code):
c = code[i]
result.append(c)
if c == '\\':
i += 1
if i < len(code):
result.append(code[i])
elif c == '"':
break
i += 1
i += 1
continue

# Block comment — always preserve
if c == '/' and i + 1 < len(code) and code[i + 1] == '*':
result.append(c)
i += 1
result.append(code[i])
i += 1
while i < len(code):
if code[i] == '*' and i + 1 < len(code) and code[i + 1] == '/':
result.append(code[i])
result.append(code[i + 1])
i += 2
break
result.append(code[i])
i += 1
continue

# Line comment
if c == '/' and i + 1 < len(code) and code[i + 1] == '/':
if depth > 0:
# Inside an expression — strip to end of line (keep the newline)
while i < len(code) and code[i] != '\n':
i += 1
else:
# Statement level — preserve
result.append(c)
i += 1
continue

# Track expression depth for ( and [ only
if c in '([':
depth += 1
elif c in ')]':
depth = max(0, depth - 1)

result.append(c)
i += 1

return ''.join(result)


def _merge_inline_comments(raw: str, body_nodes: list[ASTNode]) -> str:
"""Merge CommentLine nodes that were trailing on the same source line as the
preceding statement back onto that line.

The parser emits `var = val; // [min:max]` as two separate nodes (Assignment +
CommentLine) even though they shared one source line. to_openscad() renders them
on consecutive lines. This post-processor detects those pairs via position.line
equality, then merges the comment into the preceding output line.

A multiset (Counter) of comment texts is used so duplicate annotation texts
(e.g. two variables both annotated `// [0:100]`) are each matched at most once.
"""
from collections import Counter

# Pre-scan: collect comment texts that must be merged, preserving multiplicity.
inline_texts: Counter[str] = Counter()
prev: ASTNode | None = None
for node in body_nodes:
if (
isinstance(node, CommentLine)
and prev is not None
and not isinstance(prev, (CommentLine, CommentSpan))
):
node_line = getattr(getattr(node, "position", None), "line", None)
prev_line = getattr(getattr(prev, "position", None), "line", None)
if node_line is not None and node_line == prev_line:
inline_texts[node.text] += 1
prev = node

if not inline_texts:
return raw

remaining = Counter(inline_texts)
result: list[str] = []
for line in raw.split("\n"):
stripped = line.strip()
if (
stripped.startswith("//")
and result
and result[-1].rstrip().endswith(";")
):
comment_text = stripped[2:] # text after the `//`
if remaining.get(comment_text, 0) > 0:
result[-1] = result[-1].rstrip() + f" //{comment_text}"
remaining[comment_text] -= 1
continue
result.append(line)

return "\n".join(result)


class PackerError(Exception):
pass


class Packer:
"""Packs an OpenSCAD entry file and all its dependencies into one file."""

def __init__(self, entry_file: str, library_paths: list[str]) -> None:
def __init__(
self,
entry_file: str,
library_paths: list[str],
preserve_comments: bool = True,
) -> None:
self.entry_file = os.path.abspath(entry_file)
self.library_paths = [os.path.abspath(p) for p in library_paths]
self.preserve_comments = preserve_comments

# Definition pool: name → list of nodes, insertion-ordered via _pool_order.
# A name may have both a FunctionDeclaration and a ModuleDeclaration (OpenSCAD
Expand All @@ -57,7 +191,7 @@ def __init__(self, entry_file: str, library_paths: list[str]) -> None:

def pack(self) -> str:
"""Run both phases and return pretty-printed packed source."""
nodes = getASTfromFile(self.entry_file, process_includes=False) or []
nodes = self._parse_entry_file()
self._process_file_nodes(nodes, self.entry_file)

seed = collect_called_names(self._body_nodes)
Expand All @@ -69,7 +203,46 @@ def pack(self) -> str:
for defn in self._pool[n]
]

return to_openscad(used_defs + self._body_nodes)
# Put assignments/comments before library defs so Customizer sees them at
# the top of the file; leave geometry calls (module calls etc.) after defs.
# OpenSCAD's declarative semantics mean order doesn't affect rendering.
_is_header = (Assignment, CommentLine, CommentSpan)
header_nodes = [n for n in self._body_nodes if isinstance(n, _is_header)]
footer_nodes = [n for n in self._body_nodes if not isinstance(n, _is_header)]

raw = to_openscad(header_nodes + used_defs + footer_nodes)
return _merge_inline_comments(raw, self._body_nodes)

def _parse_entry_file(self) -> list[ASTNode]:
if not self.preserve_comments:
return getASTfromFile(self.entry_file, process_includes=False) or []

# Pre-process: strip `//` comments inside expressions so that statement-level
# magic comments (e.g. `// [0:100]`, `/* [Section] */`) survive the parse.
with open(self.entry_file, encoding="utf-8") as fh:
source = fh.read()
safe_source = _strip_expression_comments(source)

_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
nodes = getASTfromString(
safe_source,
include_comments=True,
origin=self.entry_file,
)

if nodes is not None:
return nodes

# Fallback: the pre-processed source still couldn't be parsed with comments.
nodes = getASTfromFile(self.entry_file, process_includes=False) or []
if nodes:
print(
"Warning: comments could not be preserved; packing without comments. "
"Pass --no-preserve-comments to suppress this warning.",
file=sys.stderr,
)
return nodes

# ------------------------------------------------------------------
# Phase 1: collect
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ def test_nonexistent_library_path_dir_exits_nonzero(self, runner, tmp_path):
assert result.exit_code != 0


# ---------------------------------------------------------------------------
# pack — preserve-comments flag
# ---------------------------------------------------------------------------

class TestPreserveCommentsFlag:
def test_comments_preserved_by_default(self, runner, tmp_path):
entry = write(tmp_path, "entry.scad", "// My comment\ncube(10);")
result = runner.invoke(cli, ["pack", str(entry)])
assert result.exit_code == 0
assert "My comment" in result.output

def test_preserve_comments_flag_explicit(self, runner, tmp_path):
entry = write(tmp_path, "entry.scad", "// My comment\ncube(10);")
result = runner.invoke(cli, ["pack", str(entry), "--preserve-comments"])
assert result.exit_code == 0
assert "My comment" in result.output

def test_no_preserve_comments_strips_comments(self, runner, tmp_path):
entry = write(tmp_path, "entry.scad", "// My comment\ncube(10);")
result = runner.invoke(cli, ["pack", str(entry), "--no-preserve-comments"])
assert result.exit_code == 0
assert "My comment" not in result.output


# ---------------------------------------------------------------------------
# pack — help
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading