adxc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adxc/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ import tempfile
2
+ import zipfile
3
+ from pathlib import Path
4
+
5
+ import copy
6
+
7
+ from .fields import SensorField
8
+ from .library import Robot, Track, Gantry, Positioner, Process, WireArcMode, SensorShape, Sensor
9
+ from .cell import Cell, _build_adxc, _uri_to_path
10
+ from .nozzle import Nozzle
11
+
12
+
13
+ def _validate_meshes(cell):
14
+ objects = list(cell.robot.heads if cell.robot is not None else ())
15
+ objects.extend(cell.unknown_pending_heads)
16
+ objects.extend(cell.cell_elements)
17
+ for obj in objects:
18
+ filepath = getattr(obj, "filepath", "")
19
+ if filepath:
20
+ source = _uri_to_path(filepath)
21
+ if not source.is_file():
22
+ raise FileNotFoundError(f"Mesh file not found: {source}")
23
+
24
+
25
+ def save(value, output_dir="."):
26
+ """Save one cell or nozzle using a default path in the current directory."""
27
+ output_dir = Path(output_dir)
28
+ if isinstance(value, Cell):
29
+ _validate_meshes(value)
30
+ file_stem = value.name.replace(" ", "_")
31
+ output_dir = output_dir / value.name
32
+ output_dir.mkdir(parents=True, exist_ok=True)
33
+ path = output_dir / f"{file_stem}.adxc"
34
+ path.write_bytes(_build_adxc(value))
35
+ return path
36
+ if isinstance(value, Nozzle):
37
+ path = output_dir if output_dir.suffix == ".adxn" else output_dir / value.filename
38
+ return value.write(path)
39
+ raise TypeError("value must be a Cell or Nozzle")
40
+
41
+
42
+ def savezip(value, output_dir="."):
43
+ """Save a cell or one or more nozzles as an archive."""
44
+ output_dir = Path(output_dir)
45
+
46
+ if isinstance(value, Cell):
47
+
48
+ _validate_meshes(value)
49
+ cell = copy.deepcopy(value)
50
+ resources = {}
51
+ for obj in ([*cell.robot.heads] if cell.robot is not None else []) + cell.unknown_pending_heads + cell.cell_elements:
52
+ if obj.filepath:
53
+ source = _uri_to_path(obj.filepath)
54
+ resources[source.name] = source
55
+ obj.filepath = f"./resource/{source.name}"
56
+ stem = value.name
57
+ output_dir.mkdir(parents=True, exist_ok=True)
58
+ zip_path = output_dir / f"{stem}.adxczip"
59
+
60
+ with tempfile.TemporaryDirectory() as tmp:
61
+ folder = Path(tmp) / stem
62
+ resource_folder = folder / "resource"
63
+ resource_folder.mkdir(parents=True, exist_ok=True)
64
+ file_stem = stem.replace(" ", "_")
65
+ (folder / f"{file_stem}.adxc").write_bytes(_build_adxc(cell))
66
+ for name, source in resources.items():
67
+ (resource_folder / name).write_bytes(source.read_bytes())
68
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
69
+ for file in folder.rglob("*"):
70
+ if file.is_file():
71
+ archive.write(file, file.relative_to(folder))
72
+ return zip_path
73
+
74
+ if isinstance(value, Nozzle):
75
+ nozzles = [value]
76
+
77
+ elif isinstance(value, (list, tuple)) and all(isinstance(item, Nozzle) for item in value):
78
+ nozzles = list(value)
79
+
80
+ else:
81
+ raise TypeError("value must be a Cell, Nozzle, or list of Nozzle objects")
82
+
83
+ if not nozzles:
84
+ raise ValueError("at least one nozzle is required")
85
+
86
+ path = output_dir if output_dir.suffix == ".zip" else output_dir / "nozzles.zip"
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ with tempfile.TemporaryDirectory() as temporary_directory:
89
+ temporary_directory = Path(temporary_directory)
90
+ files = []
91
+ for nozzle in nozzles:
92
+ temporary_path = temporary_directory / nozzle.filename
93
+ nozzle.write(temporary_path)
94
+ files.append(temporary_path)
95
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
96
+ for temporary_path in files:
97
+ archive.write(temporary_path, temporary_path.name)
98
+ return path
99
+
100
+ __all__ = [
101
+ "Cell", "Process", "SensorShape", "Sensor", "SensorField", "WireArcMode", "Robot", "Track",
102
+ "Gantry", "Positioner", "save", "savezip",
103
+ ]
adxc/cell.py ADDED
@@ -0,0 +1,558 @@
1
+ from __future__ import annotations
2
+ import struct, copy, time
3
+ from pathlib import Path
4
+ from dataclasses import dataclass, fields
5
+ from urllib.parse import urlparse, unquote
6
+ from .protobuf import write_protobuf
7
+ from .library import *
8
+ from .fields import *
9
+
10
+ # fixed HeadMessage slots; a sensor shape's leading params fill these positionally.
11
+ _SENSOR_SLOTS = ("sensor_near", "sensor_far", "sensor_size", "sensor_height")
12
+
13
+
14
+ @dataclass
15
+ class Head(HeadFields):
16
+
17
+ def add_toolframe(
18
+ self,
19
+ name="",
20
+ translation=None,
21
+ rotation=None,
22
+ process=None,
23
+ mode=None,
24
+ sensor=None,
25
+ near=None,
26
+ far=None,
27
+ length=None,
28
+ radius=None,
29
+ width=None,
30
+ height=None,
31
+ sensor_translation=None,
32
+ sensor_rotation=None,
33
+ ):
34
+
35
+ if isinstance(name, SensorField):
36
+ sensor, name = name, ""
37
+ if isinstance(sensor, SensorField):
38
+ sensor = {field.name: getattr(sensor, field.name)
39
+ for field in fields(sensor) if getattr(sensor, field.name) is not None}
40
+
41
+
42
+ if sensor is not None and not isinstance(sensor, dict):
43
+ sensor = {"shape": sensor}
44
+ sensor.update({name: value for name, value in {
45
+ "near": near, "far": far, "length": length,
46
+ "radius": radius, "width": width,
47
+ "height": height,
48
+ }.items() if value is not None})
49
+ if sensor_translation is not None:
50
+ sensor["translation"] = sensor_translation
51
+ if sensor_rotation is not None:
52
+ sensor["rotation"] = sensor_rotation
53
+ elif sensor is not None and any(value is not None for value in (
54
+ near, far, length, radius, width, height,
55
+ sensor_translation, sensor_rotation)):
56
+ raise TypeError("sensor dimensions must be passed as keyword arguments, not with a sensor dict")
57
+ process_kwargs = {}
58
+ if process:
59
+ process = Process.resolve(process)
60
+ process_kwargs["process"] = GUID(process.equipment_id)
61
+ process_mode = process.mode
62
+ if mode is not None:
63
+ process_mode = mode if isinstance(mode, int) else WireArcMode[str(mode).upper()].value
64
+ process_kwargs["mode"] = process_mode
65
+ if sensor:
66
+ shape = SensorShape.resolve(sensor.get("shape"))
67
+ allowed = shape.params
68
+ for pname, pval in sensor.items():
69
+ if pname in ("shape", "translation", "rotation"):
70
+ continue
71
+ if pname not in allowed:
72
+ raise ValueError(f"{shape.label} accepts {list(allowed)}; got unexpected {pname!r}")
73
+ process_kwargs[_SENSOR_SLOTS[allowed.index(pname)]] = float(pval)
74
+ sensor["shape"] = shape.label
75
+ process_kwargs["sensor_on"] = shape.code
76
+ if sensor.get("translation") is not None:
77
+ process_kwargs["sensor_translation"] = _as_vec(sensor["translation"])
78
+ if sensor.get("rotation") is not None:
79
+ process_kwargs["sensor_rotation"] = _as_vec(sensor["rotation"])
80
+
81
+ self._frames.append({
82
+ "name": name,
83
+ "translation": translation,
84
+ "rotation": rotation,
85
+ "process": process,
86
+ "mode": mode,
87
+ "sensor": sensor,
88
+ "process_kwargs": process_kwargs
89
+ })
90
+ return self
91
+
92
+
93
+ def _as_vec(v):
94
+ if v is None or isinstance(v, EmptyMessage):
95
+ return EmptyMessage()
96
+ if isinstance(v, Vec3):
97
+ return EmptyMessage() if not any((v.x, v.y, v.z)) else v
98
+ return EmptyMessage() if not any(v) else Vec3(*v)
99
+
100
+
101
+ def _normalize_transform(value):
102
+ return EmptyMessage() if value is None or not any(value) else Vec3(*value)
103
+
104
+
105
+ def _normalize_translation_rotation(value):
106
+ for field_name in ("translation", "rotation"):
107
+ field_value = getattr(value, field_name)
108
+ if isinstance(field_value, (list, tuple)):
109
+ setattr(value, field_name, _normalize_transform(field_value))
110
+ return value
111
+
112
+
113
+ def _make_holders():
114
+ return [ToolFrame(ToolFrameData(field_3=None, name="fake_holder")),
115
+ ToolFrame(ToolFrameData(field_3=None, name="fake_holder"))]
116
+
117
+
118
+ def _sensor_settings(sensor):
119
+ if not sensor:
120
+ return None
121
+ shape = SensorShape.resolve(sensor.get("shape"))
122
+ parameters = SensorParameters(
123
+ shape=shape.code,
124
+ near_or_length=(sensor.get("far") if shape.label in ("linear", "conic", "rectangle")
125
+ else sensor.get("length")),
126
+ far_or_size=(sensor.get("width") or sensor.get("radius")),
127
+ height=sensor.get("height"),
128
+ )
129
+ return SensorSettings(
130
+ shape=shape.code,
131
+ translation=_as_vec(sensor.get("translation")),
132
+ rotation=_as_vec(sensor.get("rotation")),
133
+ parameters=parameters,
134
+ offset=_as_vec(sensor.get("translation")),
135
+ )
136
+
137
+
138
+ _EMPTY_FRAME = {"name": "", "translation": None, "rotation": None,
139
+ "process": None, "mode": None, "sensor": None}
140
+
141
+
142
+ def finalize_head(head: Head):
143
+ """Return (HeadMessage, [Tool, ...]). One head layout; the toolframe count
144
+ sets multi_process (0 for <=1, 1 for 2+). Process is PER-TOOLFRAME: a single
145
+ toolframe folds it into the head; 2+ put each on its own toolframe."""
146
+ holders = _make_holders()
147
+
148
+ if len(head._frames) <= 1: # ---- single-process ----
149
+ f = head._frames[0] if head._frames else dict(
150
+ _EMPTY_FRAME, translation=head.translation, rotation=head.rotation)
151
+ proc = f.get("process_kwargs", {})
152
+ tcp_t, tcp_r = _as_vec(f["translation"]), _as_vec(f["rotation"])
153
+ holders[0].data.translation = copy.deepcopy(tcp_t) # both holders mirror the TCP
154
+ holders[1].data.translation = copy.deepcopy(tcp_t)
155
+ msg = HeadMessage(
156
+ id=head.id, filepath=head.filepath,
157
+ translation=tcp_t, rotation=tcp_r,
158
+ model=head.model, brand=head.brand,
159
+ toolframes=holders, file_name=head.file_name, multi_process=0, **proc,
160
+ )
161
+ tools = [Tool(used=-1, name=f["name"] or head.tool_id or "tool1",
162
+ head=head.id, toolframe=null_guid())]
163
+ return msg, tools
164
+
165
+ user, tools = [], [] # ---- multi-process ----
166
+ # Per-toolframe process GUID goes at 1.5.25[i].1.12. The richer .2 settings
167
+ # block (.2.10 shape/params per process) is the next step - see TODO below.
168
+ for index, f in enumerate(head._frames):
169
+ frame_id = GUID()
170
+ proc_guid = f.get("process_kwargs", {}).get("process", null_guid())
171
+ user.append(ToolFrame(ToolFrameData(
172
+ translation=_as_vec(f["translation"]), rotation=_as_vec(f["rotation"]),
173
+ name=f["name"] or f"Name {index + 1}", id=frame_id,
174
+ nozzle_holder_ref=proc_guid, # field 12 = per-toolframe process GUID
175
+ created=int(time.time() * 1000),
176
+ ), settings=_sensor_settings(f["sensor"])))
177
+ tools.append(Tool(used=-1, name=f["name"] or f"tool{index + 1}",
178
+ head=head.id, toolframe=frame_id))
179
+
180
+ msg = HeadMessage(
181
+ id=head.id,
182
+ filepath=head.filepath,
183
+ model=head.model,
184
+ brand=head.brand,
185
+ toolframes=holders + user,
186
+ file_name=head.file_name,
187
+ multi_process=1,
188
+ )
189
+ return msg, tools
190
+
191
+
192
+ @dataclass
193
+ class Cell(CellField):
194
+
195
+ def add_workframe(self,
196
+ workoffset_index=1,
197
+ id=None,
198
+ name="",
199
+ translation=None,
200
+ rotation=None,
201
+ min_x=None,
202
+ min_y=None,
203
+ max_x=None,
204
+ max_y=None,
205
+ grid_spacing=None,
206
+ hidden=False
207
+ ):
208
+
209
+ self.work_frames[0].hidden = True
210
+
211
+ workframe = WorkFrame(
212
+ name=name, translation=translation, rotation=rotation,
213
+ min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y,
214
+ grid_spacing=grid_spacing, hidden=hidden,
215
+ )
216
+ if id is not None:
217
+ workframe.id = id
218
+
219
+ if len(self.work_frames) == 1:
220
+ workframe.active = 1
221
+
222
+ if isinstance(workframe.translation, (list, tuple)):
223
+ workframe.translation = (EmptyMessage()
224
+ if not any(workframe.translation)
225
+ else Vec3(*workframe.translation)
226
+ )
227
+
228
+ if isinstance(workframe.rotation, (list, tuple)):
229
+ rx, ry, rz = workframe.rotation
230
+ # AdaOne's workframe rotation message stores Y in slot 1 and X in slot 2.
231
+ workframe.rotation = (EmptyMessage()
232
+ if not any(workframe.rotation)
233
+ else Vec3(ry, rx, rz)
234
+ )
235
+
236
+ self.work_frames.append(workframe)
237
+
238
+ self.kinematics.workoffsets = self.kinematics.workoffsets or WorkOffsets()
239
+ self.kinematics.workoffsets.offsets[workoffset_index].workframe = workframe.id
240
+
241
+
242
+ def add_robot(self, definition):
243
+
244
+ self.work_frames[0].hidden = True
245
+
246
+ robot_chain = GUID()
247
+ target = RobotPosition(
248
+ guid=GUID(),
249
+ joint_angles=struct.pack("<6f", 0.0, 0.0, -90.0, 0.0, 15.0, 0.0),
250
+ external_axes=struct.pack("<6f", *([float("inf")] * 6)),
251
+ flag=1,
252
+ name="Default home position",
253
+ )
254
+
255
+ robot_base = GUID()
256
+ self.robot = Robot(
257
+ kinematic_chain=robot_chain,
258
+ robot_model=GUID(definition["model"]),
259
+ current_position=target.joint_angles,
260
+ targets=[target],
261
+ robot_base=robot_base,
262
+ default_target=target.guid,
263
+ axis_max=struct.pack("<6f", *definition["axis_max"]),
264
+ axis_min=struct.pack("<6f", *definition["axis_min"]),
265
+ )
266
+ self.kinematics.robot_base = robot_chain
267
+ self.kinematics.workoffsets = WorkOffsets(robot_chain=robot_chain)
268
+ self.kinematics.workoffsets.offsets[0].workframe = robot_base
269
+ self.kinematics.print_chain = [Chain(robot=GUID(guid)) for guid in definition["chains"]]
270
+ for axis in self.kinematics.external_axes:
271
+ axis.parent_chain = self.robot.kinematic_chain
272
+
273
+ # pending heads added before the robot now belong to it; the tool wiring
274
+ # happens in _finalize_heads (count-driven), not here.
275
+ self.robot.heads.extend(self.unknown_pending_heads)
276
+ self.unknown_pending_heads.clear()
277
+ if self.robot.heads:
278
+ self.robot.active_head = self.robot.heads[-1]
279
+
280
+ self.kinematics.version = 0
281
+ self.kinematics.app_name = "AdaOne"
282
+ return self
283
+
284
+ def add_track(self,
285
+ definition,
286
+ kinematic_chain=None,
287
+ equipment_id=None,
288
+ translation=None,
289
+ rotation=None,
290
+ axis_mode=1,
291
+ track_properties=None,
292
+ equipment_kind=None,
293
+ stroke=None
294
+ ):
295
+ self.work_frames[0].hidden = True
296
+
297
+ track = Equipment(
298
+ kinematic_chain=kinematic_chain,
299
+ equipment_id=equipment_id,
300
+ translation=translation,
301
+ rotation=rotation,
302
+ axis_mode=axis_mode,
303
+ track_properties=track_properties,
304
+ equipment_kind=equipment_kind,
305
+ stroke=stroke,
306
+ )
307
+ _normalize_translation_rotation(track)
308
+
309
+ track.kinematic_chain = track.kinematic_chain or GUID()
310
+ track.equipment_id = track.equipment_id or GUID(definition["equipment_id"])
311
+ track.track_properties = track.track_properties or TrackProperties()
312
+ track.track_properties.size = definition["size"]
313
+ track.track_properties.rotation = definition.get("rotation") or None
314
+ track.track_properties.riser = definition.get("riser") or None
315
+ track.field_5 = track.field_5 or struct.pack("<f", 0.0)
316
+ track.stroke = 0.0 if track.stroke is None else track.stroke
317
+ track.stroke_range = track.stroke_range or struct.pack("<2f", 0.0, definition["length"])
318
+ self.equipment.append(track)
319
+ self.kinematics.external_axes.append(
320
+ ExternalAxis(
321
+ track_chain=track.kinematic_chain,
322
+ axis_type=definition["axis_type"],
323
+ parent_chain=(self.robot.kinematic_chain
324
+ if self.robot is not None else null_guid()),
325
+ )
326
+ )
327
+ return self
328
+
329
+ def add_gantry(self,
330
+ definition,
331
+ kinematic_chain=None,
332
+ equipment_id=None,
333
+ translation=None,
334
+ rotation=None,
335
+ axis_mode=3,
336
+ track_properties=None,
337
+ equipment_kind=None,
338
+ stroke=None
339
+ ):
340
+
341
+ self.work_frames[0].hidden = True
342
+
343
+ gantry = Equipment(
344
+ kinematic_chain=kinematic_chain, equipment_id=equipment_id,
345
+ translation=translation, rotation=rotation,
346
+ axis_mode=axis_mode, track_properties=track_properties,
347
+ equipment_kind=equipment_kind, stroke=stroke,
348
+ )
349
+ _normalize_translation_rotation(gantry)
350
+
351
+ gantry.kinematic_chain = gantry.kinematic_chain or GUID()
352
+ gantry.equipment_id = gantry.equipment_id or GUID(definition["equipment_id"])
353
+ gantry.axis_mode = 3
354
+ gantry.field_5 = gantry.field_5 or struct.pack("<3f", 0.0, 0.0, 0.0)
355
+ gantry.track_properties = None
356
+ self.equipment.append(gantry)
357
+ parent_chain = self.robot.kinematic_chain if self.robot else null_guid()
358
+
359
+ self.kinematics.external_axes.append(
360
+ ExternalAxis(
361
+ axis_type=3,
362
+ track_chain=gantry.kinematic_chain,
363
+ parent_chain=parent_chain
364
+ )
365
+ )
366
+ for axis_index in range(1, definition["axes"] + 1):
367
+ self.kinematics.external_axes.append(
368
+ ExternalAxis(
369
+ axis_type=3,
370
+ axis_index=axis_index,
371
+ track_chain=gantry.kinematic_chain,
372
+ parent_chain=parent_chain
373
+ )
374
+ )
375
+ return self
376
+
377
+ def add_positioner(self,
378
+ definition,
379
+ kinematic_chain=None,
380
+ equipment_id=None,
381
+ translation=None,
382
+ rotation=None,
383
+ axis_mode=0,
384
+ track_properties=None,
385
+ equipment_kind=None,
386
+ stroke=None
387
+ ):
388
+
389
+ self.work_frames[0].hidden = True
390
+
391
+
392
+ positioner = Equipment(
393
+ kinematic_chain=kinematic_chain, equipment_id=equipment_id,
394
+ translation=translation, rotation=rotation,
395
+ axis_mode=axis_mode, track_properties=track_properties,
396
+ equipment_kind=equipment_kind, stroke=stroke,
397
+ )
398
+
399
+ for field_name in ("translation", "rotation"):
400
+ value = getattr(positioner, field_name)
401
+ if isinstance(value, (list, tuple)):
402
+ setattr(positioner, field_name, EmptyMessage() if not any(value) else Vec3(
403
+ x=value[0], y=value[1], z=value[2]
404
+ ))
405
+
406
+ positioner.kinematic_chain = positioner.kinematic_chain or GUID()
407
+ positioner.equipment_id = positioner.equipment_id or GUID(definition["equipment_id"])
408
+ positioner.axis_mode = 0
409
+ positioner.track_properties = None
410
+ positioner.field_5 = positioner.field_5 or struct.pack("<2f", *definition["home_position"])
411
+ positioner.equipment_kind = 2
412
+ self.equipment.append(positioner)
413
+ axes = definition["axes"]
414
+ axis_indices = [None] if axes == 1 else list(range(1, axes + 1))
415
+
416
+ for axis_index in axis_indices:
417
+ self.kinematics.external_axes.append(
418
+ ExternalAxis(axis_index=axis_index,
419
+ track_chain=positioner.kinematic_chain,
420
+ axis_enabled=1, axis_type=0,
421
+ parent_chain=null_guid())
422
+ )
423
+ return self
424
+
425
+ def add_head(self,
426
+ tool_id=None,
427
+ id=None,
428
+ filepath="",
429
+ translation=None,
430
+ rotation=None,
431
+ model="",
432
+ brand="",
433
+ nozzle_holder_ref=None,
434
+ file_name="",
435
+ frame=None,
436
+ name=None
437
+ ):
438
+
439
+ self.work_frames[0].hidden = True
440
+
441
+ if frame is not None and not isinstance(frame, Frame):
442
+ raise TypeError("frame must be a Frame instance")
443
+
444
+ if frame is not None:
445
+ translation = translation if translation is not None else frame.translation
446
+ rotation = rotation if rotation is not None else frame.rotation
447
+
448
+ head = Head(
449
+ id=id, filepath=filepath, translation=translation, rotation=rotation,
450
+ model=model, brand=brand, nozzle_holder_ref=nozzle_holder_ref,
451
+ file_name=file_name,
452
+ )
453
+
454
+ head.tool_id = tool_id or name
455
+
456
+ normalized = str(head.filepath).replace("\\", "/")
457
+ head.id = head.id or GUID()
458
+ head.filepath = _file_uri(head.filepath)
459
+ head.file_name = head.file_name or normalized.rsplit("/", 1)[-1]
460
+ head.model = head.model or f"Process head {len(self.robot.heads) if self.robot else 0}"
461
+ head.nozzle_holder_ref = head.nozzle_holder_ref or null_guid()
462
+
463
+ if self.robot is None:
464
+ self.unknown_pending_heads.append(head)
465
+ else:
466
+ self.robot.heads.append(head)
467
+ self.robot.active_head = self.robot.heads[-1]
468
+
469
+ return head
470
+
471
+ def add_element(self,
472
+ name="",
473
+ filepath="",
474
+ translation=None,
475
+ rotation=None,
476
+ workframe=None,
477
+ payload=None,
478
+ parent=None,
479
+ appearance=None,
480
+ id=None,
481
+ ):
482
+
483
+ self.work_frames[0].hidden = True
484
+
485
+ element = CellElement(
486
+ id=id, filepath=filepath, translation=translation, rotation=rotation,
487
+ payload=payload, parent=parent, workframe=workframe,
488
+ appearance=appearance, name=name,
489
+ )
490
+
491
+ if isinstance(element.workframe, WorkFrame):
492
+ element.workframe = element.workframe.id
493
+
494
+ if element.parent is None and self.robot is not None:
495
+ element.parent = self.robot.robot_base
496
+
497
+ for field_name in ("translation", "rotation"):
498
+ value = getattr(element, field_name)
499
+ if isinstance(value, (list, tuple)):
500
+ setattr(element, field_name, EmptyMessage() if not any(value) else Vec3(
501
+ x=value[0], y=value[1], z=value[2]
502
+ ))
503
+
504
+ normalized = str(element.filepath).replace("\\", "/")
505
+ uri = _file_uri(element.filepath)
506
+ element.id = element.id or GUID()
507
+ element.filepath = uri
508
+ element.name = element.name or Path(normalized).stem
509
+ self.cell_elements.append(element)
510
+ return self
511
+
512
+ def _file_uri(filepath):
513
+ normalized = str(filepath).replace("\\", "/")
514
+ if normalized.startswith("file://"):
515
+ return normalized
516
+ if len(normalized) >= 2 and normalized[1] == ":":
517
+ return "file:///" + normalized
518
+ return Path(normalized).resolve().as_uri()
519
+
520
+
521
+ def _uri_to_path(uri: str) -> Path:
522
+ raw = unquote(urlparse(uri).path)
523
+ if raw and raw[0] == "/" and len(raw) > 2 and raw[2] == ":":
524
+ raw = raw[1:]
525
+ return Path(raw)
526
+
527
+
528
+ def _finalize_heads(cell: Cell) -> None:
529
+ """Convert every accumulator Head into its concrete single/multi message and
530
+ wire the resulting tools into kinematics. The toolframe count decides the
531
+ layout (0-1 -> single, 2+ -> multi)."""
532
+ concrete: dict[int, object] = {}
533
+
534
+ def convert(head):
535
+ if id(head) in concrete:
536
+ return concrete[id(head)]
537
+ message, tools = finalize_head(head)
538
+ concrete[id(head)] = message
539
+ if tools:
540
+ robot_chain = cell.robot.kinematic_chain if cell.robot is not None else None
541
+ cell.kinematics.tools = cell.kinematics.tools or Tools(robot_chain=robot_chain)
542
+ cell.kinematics.tools.tools.extend(tools)
543
+ return message
544
+
545
+ if cell.robot is not None:
546
+ cell.robot.heads = [convert(head) if isinstance(head, Head) else head
547
+ for head in cell.robot.heads]
548
+ if isinstance(cell.robot.active_head, Head):
549
+ cell.robot.active_head = concrete.get(id(cell.robot.active_head),
550
+ convert(cell.robot.active_head))
551
+
552
+ cell.unknown_pending_heads = [convert(head) if isinstance(head, Head) else head
553
+ for head in cell.unknown_pending_heads]
554
+
555
+
556
+ def _build_adxc(cell: Cell) -> bytes:
557
+ _finalize_heads(cell)
558
+ return write_protobuf(cell)