-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli_builder.py
More file actions
557 lines (455 loc) · 17.9 KB
/
cli_builder.py
File metadata and controls
557 lines (455 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
"""
CLI Builder - Single source of truth for kicad-cli command construction.
This module is the ONLY place that builds CLI commands for kicad-cli pcb render.
All other modules must use this to construct CLI commands.
Architecture:
Template (JSON) → Tab UI (modifications) → CLIBuilder → Validator → Command List
Usage:
from .cli_builder import CLIBuilder
builder = CLIBuilder()
# Validate a single parameter value
is_valid, error = builder.validate_param("width", 1920)
# Build complete CLI command
cmd = builder.build_command(params, kicad_cli_path, pcb_path, output_path)
# Get command as string for logging
cmd_str = builder.get_command_string(params, kicad_cli_path, pcb_path, output_path)
"""
from typing import Dict, List, Tuple, Optional, Any, Union
from dataclasses import dataclass
from enum import Enum
import os
import re
# =============================================================================
# KICAD-CLI PARAMETER SCHEMA
# This is the single source of truth for all kicad-cli pcb render parameters
# Based on: kicad-cli pcb render --help (KiCad 9.0)
# =============================================================================
@dataclass
class ParamSpec:
"""Specification for a CLI parameter."""
cli_flag: str # The CLI flag (e.g., "--width")
param_type: str # Type: "int", "float", "string", "bool", "choice", "vector3"
default: Any # Default value
required: bool = False # Is this parameter required?
choices: tuple = () # Valid choices for "choice" type
min_val: float = None # Minimum value for numeric types
max_val: float = None # Maximum value for numeric types
description: str = "" # Human-readable description
# Complete schema based on kicad-cli pcb render --help
CLI_SCHEMA: Dict[str, ParamSpec] = {
# === Dimensions ===
"width": ParamSpec(
cli_flag="--width",
param_type="int",
default=1600,
min_val=1,
max_val=16384,
description="Image width in pixels"
),
"height": ParamSpec(
cli_flag="--height",
param_type="int",
default=900,
min_val=1,
max_val=16384,
description="Image height in pixels"
),
# === View ===
"side": ParamSpec(
cli_flag="--side",
param_type="choice",
default="top",
choices=("top", "bottom", "left", "right", "front", "back"),
description="Render from side"
),
"background": ParamSpec(
cli_flag="--background",
param_type="choice",
default="transparent",
choices=("default", "transparent", "opaque"),
description="Image background"
),
# === Quality ===
"quality": ParamSpec(
cli_flag="--quality",
param_type="choice",
default="basic",
choices=("basic", "high", "user", "job_settings"),
description="Render quality (high = raytracing)"
),
"preset": ParamSpec(
cli_flag="--preset",
param_type="string",
default="follow_plot_settings",
description="Appearance preset name"
),
"use_board_stackup_colors": ParamSpec(
cli_flag="--use-board-stackup-colors",
param_type="bool",
default=False,
description="Use board stackup colors"
),
"floor": ParamSpec(
cli_flag="--floor",
param_type="bool",
default=False,
description="Enable floor, shadows and post-processing"
),
# === Camera ===
"perspective": ParamSpec(
cli_flag="--perspective",
param_type="bool",
default=False,
description="Use perspective projection (False = orthographic)"
),
"zoom": ParamSpec(
cli_flag="--zoom",
param_type="float",
default=1.0,
min_val=0.01,
max_val=100.0,
description="Camera zoom level"
),
"pan": ParamSpec(
cli_flag="--pan",
param_type="vector3",
default="",
description="Pan camera, format 'X,Y,Z'"
),
"pivot": ParamSpec(
cli_flag="--pivot",
param_type="vector3",
default="",
description="Pivot point relative to board center (cm), format 'X,Y,Z'"
),
"rotate": ParamSpec(
cli_flag="--rotate",
param_type="vector3",
default="",
description="Rotation angles, format 'X,Y,Z' (degrees)"
),
# === Lighting ===
"light_top": ParamSpec(
cli_flag="--light-top",
param_type="light",
default="",
description="Top light intensity (0-1 or R,G,B)"
),
"light_bottom": ParamSpec(
cli_flag="--light-bottom",
param_type="light",
default="",
description="Bottom light intensity (0-1 or R,G,B)"
),
"light_side": ParamSpec(
cli_flag="--light-side",
param_type="light",
default="",
description="Side lights intensity (0-1 or R,G,B)"
),
"light_camera": ParamSpec(
cli_flag="--light-camera",
param_type="light",
default="",
description="Camera light intensity (0-1 or R,G,B)"
),
"light_side_elevation": ParamSpec(
cli_flag="--light-side-elevation",
param_type="int",
default=60,
min_val=0,
max_val=90,
description="Side lights elevation angle (degrees)"
),
}
# =============================================================================
# VALIDATION HELPERS
# =============================================================================
class ValidationError(Exception):
"""Raised when a parameter value is invalid."""
pass
def validate_int(value: Any, spec: ParamSpec) -> Tuple[bool, str]:
"""Validate an integer parameter."""
try:
val = int(value)
except (ValueError, TypeError):
return False, f"Must be an integer, got: {type(value).__name__}"
if spec.min_val is not None and val < spec.min_val:
return False, f"Must be >= {spec.min_val}, got: {val}"
if spec.max_val is not None and val > spec.max_val:
return False, f"Must be <= {spec.max_val}, got: {val}"
return True, ""
def validate_float(value: Any, spec: ParamSpec) -> Tuple[bool, str]:
"""Validate a float parameter."""
try:
val = float(value)
except (ValueError, TypeError):
return False, f"Must be a number, got: {type(value).__name__}"
if spec.min_val is not None and val < spec.min_val:
return False, f"Must be >= {spec.min_val}, got: {val}"
if spec.max_val is not None and val > spec.max_val:
return False, f"Must be <= {spec.max_val}, got: {val}"
return True, ""
def validate_choice(value: Any, spec: ParamSpec) -> Tuple[bool, str]:
"""Validate a choice parameter."""
str_val = str(value).lower()
if str_val not in spec.choices:
return False, f"Must be one of: {', '.join(spec.choices)}. Got: {value}"
return True, ""
def validate_vector3(value: Any) -> Tuple[bool, str]:
"""Validate a vector3 string (X,Y,Z format)."""
if not value:
return True, "" # Empty is OK (uses default)
str_val = str(value).strip()
if not str_val:
return True, ""
# Pattern: optional minus, number, comma, repeat 3 times
pattern = r'^-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*$'
if not re.match(pattern, str_val):
return False, f"Must be format 'X,Y,Z' (e.g., '-45,0,30'). Got: {value}"
return True, ""
def validate_light(value: Any) -> Tuple[bool, str]:
"""Validate a light value (single 0-1 or R,G,B format)."""
if not value and value != 0:
return True, "" # Empty is OK
str_val = str(value).strip()
if not str_val:
return True, ""
# Try single number first
try:
val = float(str_val)
if 0.0 <= val <= 1.0:
return True, ""
return False, f"Single value must be 0.0-1.0. Got: {val}"
except ValueError:
pass
# Try R,G,B format
pattern = r'^(\d+\.?\d*),(\d+\.?\d*),(\d+\.?\d*)$'
match = re.match(pattern, str_val)
if match:
r, g, b = float(match.group(1)), float(match.group(2)), float(match.group(3))
if all(0.0 <= v <= 1.0 for v in (r, g, b)):
return True, ""
return False, f"RGB values must be 0.0-1.0. Got: {str_val}"
return False, f"Must be 0.0-1.0 or 'R,G,B' format. Got: {value}"
# =============================================================================
# CLI BUILDER CLASS
# =============================================================================
class CLIBuilder:
"""
Single source of truth for building kicad-cli pcb render commands.
This class:
1. Validates parameters against kicad-cli schema
2. Builds CLI argument lists
3. Provides string representation for logging
"""
def __init__(self):
self.schema = CLI_SCHEMA
def get_default_params(self) -> Dict[str, Any]:
"""Get dictionary of all parameters with their default values."""
return {name: spec.default for name, spec in self.schema.items()}
def validate_param(self, name: str, value: Any) -> Tuple[bool, str]:
"""
Validate a single parameter value.
Args:
name: Parameter name (e.g., "width", "quality")
value: Value to validate
Returns:
Tuple of (is_valid, error_message)
"""
if name not in self.schema:
return False, f"Unknown parameter: {name}"
spec = self.schema[name]
# Handle empty/None values
if value is None or value == "":
if spec.required:
return False, f"Required parameter '{name}' cannot be empty"
return True, ""
# Type-specific validation
if spec.param_type == "int":
return validate_int(value, spec)
elif spec.param_type == "float":
return validate_float(value, spec)
elif spec.param_type == "choice":
return validate_choice(value, spec)
elif spec.param_type == "bool":
return True, "" # Any truthy/falsy value is OK
elif spec.param_type == "string":
return True, "" # Any string is OK
elif spec.param_type == "vector3":
return validate_vector3(value)
elif spec.param_type == "light":
return validate_light(value)
return True, ""
def validate_all(self, params: Dict[str, Any]) -> List[Tuple[str, str]]:
"""
Validate all parameters.
Args:
params: Dictionary of parameter name -> value
Returns:
List of (param_name, error_message) for invalid params.
Empty list means all valid.
"""
errors = []
for name, value in params.items():
is_valid, error = self.validate_param(name, value)
if not is_valid:
errors.append((name, error))
return errors
def normalize_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize parameter values to correct types.
Args:
params: Raw parameter dictionary
Returns:
Normalized parameter dictionary
"""
normalized = {}
for name, value in params.items():
if name not in self.schema:
continue # Skip unknown params
spec = self.schema[name]
# Skip empty values
if value is None or value == "":
continue
# Normalize based on type
if spec.param_type == "int":
try:
normalized[name] = int(value)
except (ValueError, TypeError):
normalized[name] = spec.default
elif spec.param_type == "float":
try:
normalized[name] = float(value)
except (ValueError, TypeError):
normalized[name] = spec.default
elif spec.param_type == "choice":
str_val = str(value).lower()
if str_val in spec.choices:
normalized[name] = str_val
else:
normalized[name] = spec.default
elif spec.param_type == "bool":
normalized[name] = bool(value)
else:
normalized[name] = str(value) if value else ""
return normalized
def build_command(self, params: Dict[str, Any],
kicad_cli: str, pcb_path: str, output_path: str) -> List[str]:
"""
Build complete CLI command as argument list.
Args:
params: Parameter dictionary
kicad_cli: Path to kicad-cli executable
pcb_path: Path to input .kicad_pcb file
output_path: Path for output image
Returns:
List of command arguments ready for subprocess
"""
# Start with base command
cmd = [kicad_cli, "pcb", "render"]
# Normalize params first
normalized = self.normalize_params(params)
# Add parameters
for name, spec in self.schema.items():
value = normalized.get(name, spec.default)
# Skip defaults for most params (except always-include ones)
always_include = {"side", "width", "height"}
if name not in always_include:
if value == spec.default or value == "" or value is None:
continue
# Build argument based on type
if spec.param_type == "bool":
if value:
cmd.append(spec.cli_flag)
elif spec.param_type == "vector3":
if value:
str_val = str(value)
# Handle negative values with = syntax to prevent flag parsing issues
if str_val.startswith("-"):
cmd.append(f"{spec.cli_flag}={str_val}")
else:
cmd.extend([spec.cli_flag, str_val])
elif spec.param_type == "light":
if value or value == 0:
cmd.extend([spec.cli_flag, str(value)])
else:
cmd.extend([spec.cli_flag, str(value)])
# Add output and input files
cmd.extend(["-o", output_path, pcb_path])
return cmd
def get_command_string(self, params: Dict[str, Any],
kicad_cli: str = "kicad-cli",
pcb_path: str = "board.kicad_pcb",
output_path: str = "output.png") -> str:
"""
Get CLI command as string for display/logging.
Args:
params: Parameter dictionary
kicad_cli: Path to kicad-cli (default: "kicad-cli")
pcb_path: Path to PCB file
output_path: Path for output
Returns:
Command as single string
"""
cmd = self.build_command(params, kicad_cli, pcb_path, output_path)
# Quote arguments that contain spaces
quoted = []
for arg in cmd:
if " " in arg:
quoted.append(f'"{arg}"')
else:
quoted.append(arg)
return " ".join(quoted)
def get_param_spec(self, name: str) -> Optional[ParamSpec]:
"""Get the specification for a parameter."""
return self.schema.get(name)
def get_all_param_names(self) -> List[str]:
"""Get list of all valid parameter names."""
return list(self.schema.keys())
def get_choices(self, name: str) -> Optional[tuple]:
"""Get valid choices for a choice parameter."""
spec = self.schema.get(name)
if spec and spec.param_type == "choice":
return spec.choices
return None
# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================
# Global builder instance for convenience
_builder = CLIBuilder()
def build_cli_command(params: Dict[str, Any],
kicad_cli: str, pcb_path: str, output_path: str) -> List[str]:
"""
Build CLI command (convenience function).
This is the ONLY function that should be used to build CLI commands
throughout the entire plugin.
"""
return _builder.build_command(params, kicad_cli, pcb_path, output_path)
def get_cli_string(params: Dict[str, Any],
kicad_cli: str = "kicad-cli",
pcb_path: str = "board.kicad_pcb",
output_path: str = "output.png") -> str:
"""Get CLI command as string for logging."""
return _builder.get_command_string(params, kicad_cli, pcb_path, output_path)
def validate_param(name: str, value: Any) -> Tuple[bool, str]:
"""Validate a single parameter value."""
return _builder.validate_param(name, value)
def validate_all_params(params: Dict[str, Any]) -> List[Tuple[str, str]]:
"""Validate all parameters, return list of errors."""
return _builder.validate_all(params)
def get_default_params() -> Dict[str, Any]:
"""Get all default parameter values."""
return _builder.get_default_params()
def normalize_params(params: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize parameters to correct types."""
return _builder.normalize_params(params)
def get_param_choices(name: str) -> Optional[tuple]:
"""Get valid choices for a parameter."""
return _builder.get_choices(name)
def get_param_range(name: str) -> Optional[Tuple[float, float]]:
"""Get min/max range for a numeric parameter."""
spec = _builder.get_param_spec(name)
if spec and spec.param_type in ("int", "float"):
return (spec.min_val, spec.max_val)
return None