Skip to content

Commit da28523

Browse files
committed
Use VRML viewer for hardware 3D models
1 parent 30cce06 commit da28523

8 files changed

Lines changed: 229 additions & 235 deletions

File tree

.github/hardware/3d/bundle_vrml.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""Bundle KiCad VRML Inline references into one browser-viewable WRL file."""
3+
4+
import argparse
5+
import re
6+
import shutil
7+
from pathlib import Path
8+
from typing import Dict, List, Optional, Tuple
9+
10+
11+
BACKGROUND = "Background {\n skyColor [ 0.933 0.949 0.969 ]\n}\n"
12+
STRIP_TOP_LEVEL_NODES = {"WorldInfo", "NavigationInfo", "Background", "Viewpoint"}
13+
14+
15+
def parse_args() -> argparse.Namespace:
16+
parser = argparse.ArgumentParser(description=__doc__)
17+
parser.add_argument("input", type=Path, help="KiCad-exported board WRL")
18+
parser.add_argument("output", type=Path, help="Bundled output WRL")
19+
parser.add_argument(
20+
"--copy-shapes",
21+
type=Path,
22+
help="Optional destination for the source shapes3D directory",
23+
)
24+
return parser.parse_args()
25+
26+
27+
def find_matching_brace(text: str, open_index: int) -> int:
28+
depth = 0
29+
index = open_index
30+
in_string = False
31+
escaped = False
32+
while index < len(text):
33+
char = text[index]
34+
if in_string:
35+
if escaped:
36+
escaped = False
37+
elif char == "\\":
38+
escaped = True
39+
elif char == '"':
40+
in_string = False
41+
else:
42+
if char == '"':
43+
in_string = True
44+
elif char == "#":
45+
newline = text.find("\n", index)
46+
if newline == -1:
47+
return len(text) - 1
48+
index = newline
49+
elif char == "{":
50+
depth += 1
51+
elif char == "}":
52+
depth -= 1
53+
if depth == 0:
54+
return index
55+
index += 1
56+
raise ValueError("unclosed brace in VRML file")
57+
58+
59+
def iter_inline_blocks(text: str):
60+
for match in re.finditer(r"\bInline\s*\{", text):
61+
open_index = text.find("{", match.start())
62+
close_index = find_matching_brace(text, open_index)
63+
yield match.start(), close_index + 1, text[match.start() : close_index + 1]
64+
65+
66+
def extract_url(inline_block: str) -> Optional[str]:
67+
match = re.search(r"\burl\s+(?:\[\s*)?\"([^\"]+)\"", inline_block)
68+
if not match:
69+
return None
70+
return match.group(1)
71+
72+
73+
def strip_header(text: str) -> str:
74+
text = re.sub(r"^\s*#VRML\s+V2\.0\s+utf8\s*", "", text, count=1)
75+
return text.lstrip()
76+
77+
78+
def strip_top_level_nodes(text: str) -> str:
79+
output: List[str] = []
80+
index = 0
81+
node_pattern = re.compile(r"\b(" + "|".join(sorted(STRIP_TOP_LEVEL_NODES)) + r")\s*\{")
82+
while index < len(text):
83+
match = node_pattern.search(text, index)
84+
if not match:
85+
output.append(text[index:])
86+
break
87+
88+
output.append(text[index : match.start()])
89+
open_index = text.find("{", match.start())
90+
close_index = find_matching_brace(text, open_index)
91+
index = close_index + 1
92+
return "".join(output).strip()
93+
94+
95+
def normalize_url(url: str) -> str:
96+
return url.replace("\\", "/")
97+
98+
99+
def resolve_inline_path(base_dir: Path, url: str) -> Path:
100+
normalized = normalize_url(url)
101+
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", normalized):
102+
raise ValueError(f"remote VRML Inline URLs are not supported: {url}")
103+
return (base_dir / normalized).resolve()
104+
105+
106+
def bundle_body(path: Path, stack: Tuple[Path, ...], cache: Dict[Path, str]) -> str:
107+
path = path.resolve()
108+
if path in stack:
109+
chain = " -> ".join(str(item) for item in (*stack, path))
110+
raise ValueError(f"recursive VRML Inline loop: {chain}")
111+
if path in cache:
112+
return f"USE {cache[path]}"
113+
if not path.exists():
114+
raise FileNotFoundError(f"Inline VRML file not found: {path}")
115+
116+
source = path.read_text(encoding="utf-8", errors="replace")
117+
body = strip_top_level_nodes(strip_header(source))
118+
body = bundle_inlines(body, path.parent, (*stack, path), cache)
119+
120+
def_name = f"BUNDLED_{len(cache) + 1}"
121+
cache[path] = def_name
122+
return f"DEF {def_name} Group {{\n children [\n{indent(body, 4)}\n ]\n}}"
123+
124+
125+
def bundle_inlines(text: str, base_dir: Path, stack: Tuple[Path, ...], cache: Dict[Path, str]) -> str:
126+
pieces: List[str] = []
127+
cursor = 0
128+
for start, end, block in iter_inline_blocks(text):
129+
pieces.append(text[cursor:start])
130+
url = extract_url(block)
131+
if not url:
132+
raise ValueError(f"Inline block is missing a URL: {block[:120]}")
133+
pieces.append(bundle_body(resolve_inline_path(base_dir, url), stack, cache))
134+
cursor = end
135+
pieces.append(text[cursor:])
136+
return "".join(pieces)
137+
138+
139+
def indent(text: str, spaces: int) -> str:
140+
prefix = " " * spaces
141+
return "\n".join(prefix + line if line.strip() else line for line in text.splitlines())
142+
143+
144+
def add_background(text: str) -> str:
145+
body = strip_header(text)
146+
body = re.sub(r"\bBackground\s*\{[^{}]*\}\s*", "", body, count=1)
147+
return "#VRML V2.0 utf8\n" + BACKGROUND + body.lstrip()
148+
149+
150+
def copy_shapes_dir(input_path: Path, destination: Optional[Path]) -> None:
151+
if destination is None:
152+
return
153+
source = input_path.parent / "shapes3D"
154+
if not source.is_dir():
155+
return
156+
if destination.exists():
157+
shutil.rmtree(destination)
158+
shutil.copytree(source, destination)
159+
160+
161+
def main() -> int:
162+
args = parse_args()
163+
input_path = args.input.resolve()
164+
output_path = args.output.resolve()
165+
output_path.parent.mkdir(parents=True, exist_ok=True)
166+
copy_shapes_dir(input_path, args.copy_shapes)
167+
168+
source = input_path.read_text(encoding="utf-8", errors="replace")
169+
bundled = bundle_inlines(strip_header(source), input_path.parent, (input_path,), {})
170+
with output_path.open("w", encoding="utf-8", newline="\n") as output_file:
171+
output_file.write(add_background(bundled))
172+
return 0
173+
174+
175+
if __name__ == "__main__":
176+
raise SystemExit(main())

.github/hardware/3d/convert_vrml_to_glb.py

Lines changed: 0 additions & 115 deletions
This file was deleted.

.github/hardware/site/index.html

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<title>__PROJECT_NAME__ Hardware Review</title>
77
<link rel="stylesheet" href="style.css">
88
<script type="module" src="https://kicanvas.org/kicanvas/kicanvas.js"></script>
9-
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
9+
<script defer src="https://cdn.jsdelivr.net/npm/x_ite@15.1.6/dist/x_ite.min.js"></script>
1010
</head>
1111
<body>
1212
<header class="topbar">
@@ -83,11 +83,11 @@ <h3 id="diff-title">Visual diff</h3>
8383
<section id="model" class="section">
8484
<div class="section-heading">
8585
<h2>3D Model</h2>
86-
<p>The browser model viewer is enabled automatically when a GLB model is available.</p>
86+
<p>The browser VRML viewer is enabled automatically when a board model is available.</p>
8787
</div>
8888
<div id="model-container" class="model-fallback">
8989
<h3>No browser-viewable 3D model in this run</h3>
90-
<p>The 3D model job did not publish <code>model/board.glb</code> for this project.</p>
90+
<p>The 3D model job did not publish <code>model/board.wrl</code> for this project.</p>
9191
</div>
9292
</section>
9393

@@ -234,19 +234,17 @@ <h2>PDFs</h2>
234234
});
235235
}
236236

237-
fetch('model/board.glb', { method: 'HEAD' })
237+
fetch('model/board.wrl', { method: 'HEAD' })
238238
.then((response) => {
239239
if (!response.ok) return;
240240
document.getElementById('model-container').innerHTML = `
241-
<model-viewer
242-
src="model/board.glb"
243-
poster="model/board-preview.png"
244-
camera-controls
245-
auto-rotate
246-
shadow-intensity="0.8"
247-
aria-label="Interactive 3D PCB model">
248-
</model-viewer>
249-
<p class="model-actions"><a href="model/board.glb">Download GLB</a></p>
241+
<x3d-canvas
242+
src="model/board.wrl"
243+
contentScale="auto"
244+
update="auto">
245+
<p>Interactive VRML viewer unavailable in this browser.</p>
246+
</x3d-canvas>
247+
<p class="model-actions"><a href="model/board.wrl">Download VRML</a></p>
250248
`;
251249
})
252250
.catch(() => {});

.github/hardware/site/pr-preview.html

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<title>Hardware PR Preview</title>
77
<link rel="stylesheet" href="../style.css">
88
<script type="module" src="https://kicanvas.org/kicanvas/kicanvas.js"></script>
9-
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
9+
<script defer src="https://cdn.jsdelivr.net/npm/x_ite@15.1.6/dist/x_ite.min.js"></script>
1010
</head>
1111
<body>
1212
<header class="topbar">
@@ -215,23 +215,21 @@ <h3 id="diff-title">${escapeHtml(first.label || 'Visual diff')}</h3>
215215
};
216216

217217
const renderModel = (board) => {
218-
if (board.model?.glb) {
218+
if (board.model?.wrl) {
219219
return `
220-
<model-viewer
221-
src="${assetUrl(board.model.glb)}"
222-
${board.model.poster ? `poster="${assetUrl(board.model.poster)}"` : ''}
223-
camera-controls
224-
auto-rotate
225-
shadow-intensity="0.8"
226-
aria-label="Interactive 3D PCB model">
227-
</model-viewer>
228-
<p class="model-actions"><a href="${assetUrl(board.model.glb)}">Download GLB</a></p>
220+
<x3d-canvas
221+
src="${assetUrl(board.model.wrl)}"
222+
contentScale="auto"
223+
update="auto">
224+
<p>Interactive VRML viewer unavailable in this browser.</p>
225+
</x3d-canvas>
226+
<p class="model-actions"><a href="${assetUrl(board.model.wrl)}">Download VRML</a></p>
229227
`;
230228
}
231229
return `
232230
<div class="model-fallback">
233231
<h3>No browser-viewable 3D model in this run</h3>
234-
<p>The PR preview did not publish a GLB model for this board.</p>
232+
<p>The PR preview did not publish a VRML model for this board.</p>
235233
</div>
236234
`;
237235
};

.github/hardware/site/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ kicanvas-embed {
202202
min-height: 260px;
203203
}
204204

205-
model-viewer {
205+
x3d-canvas {
206206
display: block;
207207
width: 100%;
208208
height: min(640px, 72vh);

0 commit comments

Comments
 (0)