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
1,467 changes: 1,437 additions & 30 deletions docs/API_SPEC.md

Large diffs are not rendered by default.

1,661 changes: 1,645 additions & 16 deletions frontend/package-lock.json

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test:e2e": "playwright test",
"assets:gen": "python ../scripts/generate_reference_glbs.py",
"assets:pack": "bash ../scripts/pack_models.sh",
"assets:validate": "python ../scripts/glb_validate.py public/models/*.glb --fail-on-warning",
"assets:manifest": "python ../scripts/gen_glb_manifest.py > public/models/manifest.json",
"assets:all": "npm run assets:gen && npm run assets:pack && npm run assets:validate && npm run assets:manifest",
"test:manifest": "vitest run --reporter=dot"
Comment on lines +11 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Standardize Python invocation; align with pack script.

Use python3 consistently to avoid env mismatches (some systems have no python). Example:

-    "assets:gen": "python ../scripts/generate_reference_glbs.py",
+    "assets:gen": "python3 ../scripts/generate_reference_glbs.py",
-    "assets:validate": "python ../scripts/glb_validate.py public/models/*.glb --fail-on-warning",
+    "assets:validate": "python3 ../scripts/glb_validate.py public/models/*.glb --fail-on-warning",
-    "assets:manifest": "python ../scripts/gen_glb_manifest.py > public/models/manifest.json",
+    "assets:manifest": "python3 ../scripts/gen_glb_manifest.py > public/models/manifest.json",

Note: > public/models/manifest.json and the *.glb glob rely on a POSIX shell; if Windows support is needed, consider a node wrapper script.


🏁 Script executed:

#!/bin/bash
echo "=== frontend/package.json python invocations ==="
rg -n '"python ' -C2 frontend/package.json || echo "none"

echo "=== frontend/package.json python3 invocations ==="
rg -n '"python3' -C2 frontend/package.json || echo "none"

echo "=== Global python invocations ==="
rg -n '"python ' -C2 || echo "none"

echo "=== Global python3 invocations ==="
rg -n '"python3' -C2 || echo "none"

echo "=== Shebang lines in scripts/ ==="
find scripts -maxdepth 1 -type f -name "*.py" | xargs -r head -n1

Length of output: 1752


Use python3 in package.json scripts
Replace python with python3 in assets:gen, assets:validate, and assets:manifest to match the scripts’ shebangs. These commands use POSIX globs and redirection; for Windows support, consider a Node-based wrapper.

🤖 Prompt for AI Agents
In frontend/package.json around lines 11 to 16, three scripts use the plain
`python` executable which is inconsistent with the scripts' shebangs and can
fail on systems where Python 3 is `python3`; update the scripts `assets:gen`,
`assets:validate`, and `assets:manifest` to call `python3` instead of `python`.
Ensure you only change the command name (not arguments), run the updated scripts
locally to verify they work, and consider adding a short comment or README note
if Windows users need a Node-based wrapper for glob/redirection behavior.

},
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
Expand All @@ -32,6 +39,10 @@
"eslint-config-next": "14.2.0",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
"typescript": "^5",
"gltfpack": "0.25.0",
"meshoptimizer": "0.25.0",
"vitest": "^1.6.0",
"@playwright/test": "^1.56.0"
}
}
2,152 changes: 0 additions & 2,152 deletions frontend/public/models/BaseCabinet600.glb

This file was deleted.

Binary file added frontend/public/models/base_600.glb
Binary file not shown.
Binary file added frontend/public/models/base_600@lod1.glb
Binary file not shown.
34 changes: 34 additions & 0 deletions frontend/public/models/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"models": [
{
"file": "/models/base_600.glb",
"sha256": "45f4940517b77d748dc71d4efa6fc07ca4115ce92143fde4dfdcb424959b0b54",
"bytes": 4360
},
{
"file": "/models/base_600@lod1.glb",
"sha256": "c21ba9280c2a68a068b1282429c1c650303631d048492daf0e9bbc3c6c639d3e",
"bytes": 4052
},
{
"file": "/models/tall_600.glb",
"sha256": "31ec07b07df7f59c48369c96c723b8037e51f79605bf62f0ae9c1e0a53e8c99e",
"bytes": 4360
},
{
"file": "/models/tall_600@lod1.glb",
"sha256": "98a0063fc91dcbac054f5c8f88dc74f5e6f35525fc79d296c13755f6b77577e5",
"bytes": 4052
},
{
"file": "/models/wall_900.glb",
"sha256": "22c1c6d9269c079be53ca662f2583c033e57cea0b87d5d6ff80a62320e76e00d",
"bytes": 4364
},
{
"file": "/models/wall_900@lod1.glb",
"sha256": "de16699463f5a3eeda4e07ade0ca2e35ff34c3a6232fffde7c2178d844ca8304",
"bytes": 4052
}
]
}
Binary file added frontend/public/models/tall_600.glb
Binary file not shown.
Binary file added frontend/public/models/tall_600@lod1.glb
Binary file not shown.
Binary file added frontend/public/models/wall_900.glb
Binary file not shown.
Binary file added frontend/public/models/wall_900@lod1.glb
Binary file not shown.
33 changes: 33 additions & 0 deletions scripts/gen_glb_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the executable permission.

The shebang is present but the file is not marked as executable. This prevents the script from being run directly.

Apply this fix:

chmod +x scripts/gen_glb_manifest.py

Based on static analysis hints.

🧰 Tools
🪛 Ruff (0.14.0)

1-1: Shebang is present but file is not executable

(EXE001)

🤖 Prompt for AI Agents
In scripts/gen_glb_manifest.py around line 1 the shebang is present but the file
is not executable; mark the script executable by setting the execute bit (e.g.,
run chmod +x scripts/gen_glb_manifest.py) and update the repository index (git
add scripts/gen_glb_manifest.py and commit) so the script can be run directly.

"""Generate manifest.json describing GLB assets (path, sha256, size)."""
from __future__ import annotations

import glob
import hashlib
import json
import os
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
MODELS_PATTERN = ROOT / "frontend" / "public" / "models" / "*.glb"


def manifest_entry(path: Path) -> dict:
data = path.read_bytes()
return {
"file": "/models/" + path.name,
"sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data),
}
Comment on lines +15 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add error handling for file operations.

The function reads the entire file into memory and computes its hash without handling potential I/O errors (e.g., permission denied, file not found during read).

Apply this diff to add basic error handling:

 def manifest_entry(path: Path) -> dict:
-    data = path.read_bytes()
+    try:
+        data = path.read_bytes()
+    except OSError as e:
+        raise RuntimeError(f"Failed to read {path}: {e}") from e
     return {
         "file": "/models/" + path.name,
         "sha256": hashlib.sha256(data).hexdigest(),
         "bytes": len(data),
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def manifest_entry(path: Path) -> dict:
data = path.read_bytes()
return {
"file": "/models/" + path.name,
"sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data),
}
def manifest_entry(path: Path) -> dict:
try:
data = path.read_bytes()
except OSError as e:
raise RuntimeError(f"Failed to read {path}: {e}") from e
return {
"file": "/models/" + path.name,
"sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data),
}
🤖 Prompt for AI Agents
In scripts/gen_glb_manifest.py around lines 15 to 21, manifest_entry reads the
whole file and computes a hash without handling I/O errors; wrap the file read
and hashing in a try/except that catches OSError (or Exception if broader) to
handle file-not-found/permission errors, and on failure either raise a new
exception with a clear contextual message (including the path and original
error) or return a sentinel (e.g., None) depending on callers; ensure the happy
path still returns the dict with "file", "sha256", and "bytes".



def main() -> int:
paths = sorted(MODELS_PATTERN.parent.glob(MODELS_PATTERN.name))
entries = [manifest_entry(path) for path in paths]
manifest = {"models": entries}
print(json.dumps(manifest, indent=2))
return 0
Comment on lines +24 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Simplify glob usage and add validation.

Line 25 constructs the glob pattern in a convoluted way. Additionally, the function doesn't handle the case where no GLB files are found.

Apply this diff to simplify and add validation:

 def main() -> int:
-    paths = sorted(MODELS_PATTERN.parent.glob(MODELS_PATTERN.name))
+    paths = sorted(Path(MODELS_PATTERN).parent.glob("*.glb"))
+    if not paths:
+        print("Warning: No GLB files found", file=__import__("sys").stderr)
+        return 1
     entries = [manifest_entry(path) for path in paths]
     manifest = {"models": entries}
     print(json.dumps(manifest, indent=2))
     return 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def main() -> int:
paths = sorted(MODELS_PATTERN.parent.glob(MODELS_PATTERN.name))
entries = [manifest_entry(path) for path in paths]
manifest = {"models": entries}
print(json.dumps(manifest, indent=2))
return 0
def main() -> int:
paths = sorted(Path(MODELS_PATTERN).parent.glob("*.glb"))
if not paths:
print("Warning: No GLB files found", file=__import__("sys").stderr)
return 1
entries = [manifest_entry(path) for path in paths]
manifest = {"models": entries}
print(json.dumps(manifest, indent=2))
return 0
🤖 Prompt for AI Agents
In scripts/gen_glb_manifest.py around lines 24 to 29, simplify the glob usage by
building a concrete list of matched paths (e.g., cast the glob iterator to a
list and sort it) and add validation to handle the case of no matches: if the
list is empty print a clear error to stderr and return a non-zero exit code (or
sys.exit(1)); otherwise continue to build the manifest and return 0. Ensure you
import sys if needed and use print(..., file=sys.stderr) for the error, and keep
the manifest generation using the sorted list of paths.



if __name__ == "__main__":
raise SystemExit(main())
272 changes: 272 additions & 0 deletions scripts/generate_reference_glbs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Shebang vs execution mode.

Either make the script executable (chmod +x) or drop the shebang to silence linters since it’s run via python3 ....

-#!/usr/bin/env python3
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#!/usr/bin/env python3
🧰 Tools
🪛 Ruff (0.14.0)

1-1: Shebang is present but file is not executable

(EXE001)

🤖 Prompt for AI Agents
In scripts/generate_reference_glbs.py at line 1, the file currently contains a
shebang but is invoked via python3 and linters flag this; either remove the
shebang line entirely or make the script executable. To fix: if you prefer
calling it with python3, delete the "#!/usr/bin/env python3" line; if you want
it runnable directly, restore the shebang and run "chmod +x
scripts/generate_reference_glbs.py" (or set executable bit in the repo) so the
shebang is valid.

"""
Generate deterministic reference GLBs for the Paform viewer pipeline.

Emits base_600.glb, wall_900.glb, tall_600.glb into frontend/public/models/.
Each GLB:
- Uses millimetre scale with pivot/min bounds at the origin.
- Declares Module metadata under extras (code, dimensions, materials).
- Contains at least one mesh node with extras.panelType.
- Includes simple base-color textures (4x4 PNG) so texture pipelines stay engaged.
The resulting GLBs are intentionally minimal and are expected to be re-packed
via scripts/pack_models.sh to add compression + LODs.
"""
from __future__ import annotations

import base64
import json
import os
import struct
from pathlib import Path
from typing import Dict, Iterable, List, Sequence, Tuple
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Modernize typing imports.

Use built-in generics and collections.abc to address Ruff UP035.

-from typing import Dict, Iterable, List, Sequence, Tuple
+from collections.abc import Iterable, Sequence
+from typing import Dict, List, Tuple  # or prefer built-in: dict, list, tuple annotations

Optionally: replace Dict/List/Tuple with dict/list/tuple.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from typing import Dict, Iterable, List, Sequence, Tuple
from collections.abc import Iterable, Sequence
from typing import Dict, List, Tuple # or prefer built-in: dict, list, tuple annotations
🧰 Tools
🪛 Ruff (0.14.0)

21-21: Import from collections.abc instead: Iterable, Sequence

Import from collections.abc

(UP035)


21-21: typing.Dict is deprecated, use dict instead

(UP035)


21-21: typing.List is deprecated, use list instead

(UP035)


21-21: typing.Tuple is deprecated, use tuple instead

(UP035)

🤖 Prompt for AI Agents
In scripts/generate_reference_glbs.py around line 21, the file imports Dict,
Iterable, List, Sequence, Tuple from typing which triggers Ruff UP035; update
the imports to use built-in generics and collections.abc: remove Dict/List/Tuple
from typing and replace usages with built-in dict/list/tuple, import Iterable
and Sequence from collections.abc (or drop typing import entirely if only using
built-ins), and update any type annotations to the modern forms (e.g.,
list[int], dict[str, int], tuple[int, ...], Iterable[T], Sequence[T]).


ROOT = Path(__file__).resolve().parents[1]
MODELS_DIR = ROOT / "frontend" / "public" / "models"

# 4x4 PNG pixels (white & grey) encoded to ensure textures exist pre-pack and can be BasisU compressed.
PNG_WHITE = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGP8//8/AwwwwVl4OQCWbgMF7ZjH1AAAAABJRU5ErkJggg=="
)
PNG_GREY = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGM8ceIEAwwwwVl4OQB2NAJgnoBkZwAAAABJRU5ErkJggg=="
)


def pack_f32(values: Iterable[float]) -> bytes:
return b"".join(struct.pack("<f", float(v)) for v in values)


def pack_u16(values: Iterable[int]) -> bytes:
return b"".join(struct.pack("<H", int(v)) for v in values)


def pad4(data: bytes, pad: bytes = b" ") -> bytes:
remainder = (-len(data)) % 4
if remainder:
data += pad * remainder
return data


def build_box(width: float, height: float, depth: float, subdivisions: int = 2) -> Tuple[List[float], List[float], List[int]]:
"""Create a box mesh with per-face subdivisions so LOD simplification has room to operate."""

w, h, d = float(width), float(height), float(depth)
positions: List[float] = []
texcoords: List[float] = []
indices: List[int] = []

faces = [
# front (faces -Z)
((0.0, h, 0.0), (w, 0.0, 0.0), (0.0, -h, 0.0)),
# right (+X)
((w, h, 0.0), (0.0, 0.0, d), (0.0, -h, 0.0)),
# back (+Z)
((w, h, d), (-w, 0.0, 0.0), (0.0, -h, 0.0)),
# left (-X)
((0.0, h, d), (0.0, 0.0, -d), (0.0, -h, 0.0)),
# top (+Y)
((0.0, h, d), (w, 0.0, 0.0), (0.0, 0.0, -d)),
# bottom (-Y)
((0.0, 0.0, 0.0), (w, 0.0, 0.0), (0.0, 0.0, d)),
]

for origin, u_vec, v_vec in faces:
start_idx = len(positions) // 3
for v_idx in range(subdivisions + 1):
v_ratio = v_idx / subdivisions if subdivisions else 0.0
for u_idx in range(subdivisions + 1):
u_ratio = u_idx / subdivisions if subdivisions else 0.0
px = origin[0] + u_vec[0] * u_ratio + v_vec[0] * v_ratio
py = origin[1] + u_vec[1] * u_ratio + v_vec[1] * v_ratio
pz = origin[2] + u_vec[2] * u_ratio + v_vec[2] * v_ratio
positions.extend([px, py, pz])
texcoords.extend([u_ratio, v_ratio])

stride = subdivisions + 1
for v_idx in range(subdivisions):
for u_idx in range(subdivisions):
top_left = start_idx + v_idx * stride + u_idx
top_right = top_left + 1
bottom_left = top_left + stride
bottom_right = bottom_left + 1
indices.extend([top_left, bottom_left, bottom_right])
indices.extend([top_left, bottom_right, top_right])

return positions, texcoords, indices


def create_glb_document(
code: str, width: int, height: int, depth: int
) -> Tuple[Dict, bytes]:
positions, texcoords, indices = build_box(width, height, depth)
position_blob = pack_f32(positions)
texcoord_blob = pack_f32(texcoords)
index_blob = pack_u16(indices)

def add_view(blob: bytes, *, target: int | None = None) -> Tuple[int, bytes]:
nonlocal buffer_bytes, byte_offset
view = {"buffer": 0, "byteOffset": byte_offset, "byteLength": len(blob)}
if target is not None:
view["target"] = target
padded = pad4(blob, b"\x00")
buffer_bytes += padded
index = len(buffer_views)
buffer_views.append(view)
byte_offset += len(padded)
return index, padded

buffer_views: List[Dict] = []
buffer_bytes = b""
byte_offset = 0

pos_view, _ = add_view(position_blob, target=34962) # ARRAY_BUFFER
tex_view, _ = add_view(texcoord_blob, target=34962)
idx_view, _ = add_view(index_blob, target=34963) # ELEMENT_ARRAY_BUFFER
white_view, _ = add_view(PNG_WHITE)
grey_view, _ = add_view(PNG_GREY)

accessors = [
{
"bufferView": pos_view,
"componentType": 5126,
"count": len(positions) // 3,
"type": "VEC3",
"min": [0.0, 0.0, 0.0],
"max": [float(width), float(height), float(depth)],
},
{
"bufferView": tex_view,
"componentType": 5126,
"count": len(texcoords) // 2,
"type": "VEC2",
},
{
"bufferView": idx_view,
"componentType": 5123,
"count": len(indices),
"type": "SCALAR",
},
]

images = [
{"bufferView": white_view, "mimeType": "image/png"},
{"bufferView": grey_view, "mimeType": "image/png"},
]
textures = [{"source": 0}, {"source": 1}]

materials = [
{
"name": "Mat::Carcass",
"pbrMetallicRoughness": {
"baseColorTexture": {"index": 0},
"metallicFactor": 0.0,
"roughnessFactor": 0.9,
},
},
{
"name": "Mat::Front",
"pbrMetallicRoughness": {
"baseColorTexture": {"index": 1},
"metallicFactor": 0.0,
"roughnessFactor": 0.9,
},
},
]

meshes = [
{
"name": f"{code}_Body",
"primitives": [
{
"attributes": {"POSITION": 0, "TEXCOORD_0": 1},
"indices": 2,
"mode": 4,
"material": 0,
}
],
}
]

module_extras = {
"paform_glb_schema": "1.0.0",
"module": {
"code": code,
"type": "Cabinet",
"system": "Prime",
"family": "Reference",
"width_mm": width,
"height_mm": height,
"depth_mm": depth,
},
"dimensionsMm": {"width": width, "height": height, "depth": depth},
"materials": {
"carcass": "Mat::Carcass",
"front": "Mat::Front",
},
"lod": {"level": 0, "triangleCount": len(indices) // 3},
"qa": {"validatorVersion": "0.0.0"},
}

nodes = [
{
"name": f"Module::{code.upper()}",
"mesh": 0,
"extras": {**module_extras, "panelType": "carcass"},
}
]

document = {
"asset": {"version": "2.0", "generator": "paform/reference-generator"},
"scenes": [{"nodes": [0]}],
"scene": 0,
"nodes": nodes,
"meshes": meshes,
"materials": materials,
"textures": textures,
"images": images,
"buffers": [{"byteLength": len(buffer_bytes)}],
"bufferViews": buffer_views,
"accessors": accessors,
"extensionsUsed": [],
}
return document, buffer_bytes


def write_glb(path: Path, document: Dict, binary_blob: bytes) -> None:
json_bytes = pad4(json.dumps(document, separators=(",", ":")).encode("utf-8"))
bin_bytes = pad4(binary_blob, b"\x00")

total_length = 12 + 8 + len(json_bytes) + 8 + len(bin_bytes)
header = struct.pack("<4sII", b"glTF", 2, total_length)
json_chunk = struct.pack("<I4s", len(json_bytes), b"JSON")
bin_chunk = struct.pack("<I4s", len(bin_bytes), b"BIN\x00")

path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as fh:
fh.write(header)
fh.write(json_chunk)
fh.write(json_bytes)
fh.write(bin_chunk)
fh.write(bin_bytes)


def main() -> None:
MODELS_DIR.mkdir(parents=True, exist_ok=True)
legacy = MODELS_DIR / "BaseCabinet600.glb"
if legacy.exists():
legacy.unlink()

configs = [
("base_600", 600, 720, 580),
("wall_900", 900, 720, 360),
("tall_600", 600, 2100, 580),
]
for code, width, height, depth in configs:
path = MODELS_DIR / f"{code}.glb"
doc, blob = create_glb_document(code, width, height, depth)
write_glb(path, doc, blob)
print(f"Wrote {path.relative_to(ROOT)}")


if __name__ == "__main__":
main()
Loading
Loading