File size: 14,522 Bytes
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8e790e
 
29c82ca
 
859a75d
29c82ca
 
 
 
 
4d202b8
29c82ca
 
 
 
49307ad
29c82ca
 
 
 
b8e790e
 
29c82ca
 
859a75d
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c2359
 
29c82ca
67c2359
29c82ca
 
 
 
 
 
67c2359
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab2d2fa
29c82ca
ab2d2fa
 
 
 
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
ab2d2fa
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab2d2fa
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab2d2fa
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d202b8
67c2359
ab2d2fa
29c82ca
 
 
 
 
 
 
b8e790e
 
49307ad
4b11c98
29c82ca
 
 
 
 
 
 
 
 
49307ad
 
 
 
 
67c2359
 
 
 
 
29c82ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859a75d
 
 
 
4d202b8
 
859a75d
4d202b8
 
859a75d
4d202b8
49307ad
4b11c98
 
 
49307ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b11c98
 
 
 
 
 
 
49307ad
4d202b8
29c82ca
 
 
 
 
 
 
4d202b8
859a75d
4d202b8
49307ad
 
67c2359
 
 
 
 
29c82ca
 
4d202b8
 
4b11c98
 
 
 
4d202b8
 
 
 
 
4b11c98
 
4d202b8
b8e790e
 
 
29c82ca
b8e790e
29c82ca
 
 
 
b8e790e
29c82ca
b8e790e
29c82ca
 
 
 
 
 
 
 
 
 
8f71a0b
29c82ca
 
 
 
 
 
 
 
 
ab2d2fa
29c82ca
 
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
"""G-code emission for vector layer stacks.

Writes the same machine dialect as the old TIFF pipeline: G91 relative moves,
G0 travel / G1 print (or all-G1), WAGO valve commands on every valve-state
change, and serial pressure preset/toggle commands with an optional per-layer
pressure ramp.
"""

from __future__ import annotations

import tempfile
from codecs import encode
from pathlib import Path
from textwrap import wrap

from stl_slicer import LayerStack
from vector_toolpath import (
    LEAD_IN_DIRECTION_CHOICES,
    LEAD_IN_DIRECTION_LEFT,
    RASTER_PATTERN_CHOICES,
    RASTER_PATTERN_CIRCLE_SPIRAL,
    RASTER_PATTERN_DIAGONAL_WOODPILE,
    RASTER_PATTERN_RECTANGULAR_SPIRAL,
    RASTER_PATTERN_SAME_DIRECTION,
    RASTER_PATTERN_WOODPILE,
    RASTER_PATTERN_Y_DIRECTION,
    ContourSource,
    _centering_delta,
    _lead_in_moves,
    _normalize_raster_pattern,
    align_stack_to,
    build_contour_layers,
    circle_wall_radius,
    plan_layer_moves,
)

__all__ = [
    "LEAD_IN_DIRECTION_CHOICES",
    "LEAD_IN_DIRECTION_LEFT",
    "RASTER_PATTERN_CHOICES",
    "RASTER_PATTERN_CIRCLE_SPIRAL",
    "RASTER_PATTERN_DIAGONAL_WOODPILE",
    "RASTER_PATTERN_RECTANGULAR_SPIRAL",
    "RASTER_PATTERN_SAME_DIRECTION",
    "RASTER_PATTERN_WOODPILE",
    "RASTER_PATTERN_Y_DIRECTION",
    "ContourSource",
    "generate_vector_gcode",
    "write_gcode_file",
]


def _setpress(pressure: float) -> str:
    pressure_str = str(int(pressure * 10)).zfill(4)
    command_bytes = bytes("08PS  " + pressure_str, "utf-8")
    hex_command = encode(command_bytes, "hex").decode("utf-8")
    format_command = "\\x" + "\\x".join(
        hex_command[i : i + 2] for i in range(0, len(hex_command), 2)
    )

    hex_pairs = wrap(hex_command, 2)
    decimal_sum = sum(int(pair, 16) for pair in hex_pairs)
    checksum_bin = bin(decimal_sum % 256)[2:].zfill(8)
    inverted = int("".join("1" if c == "0" else "0" for c in checksum_bin), 2) + 1
    checksum_hex = hex(inverted)[2:].upper()
    format_checksum = "\\x" + "\\x".join(
        checksum_hex[i : i + 2] for i in range(0, len(checksum_hex), 2)
    )

    return "b'" + "\\x05\\x02" + format_command + format_checksum + "\\x03" + "'"


def _togglepress() -> str:
    return "b'\\x05\\x02\\x30\\x34\\x44\\x49\\x20\\x20\\x43\\x46\\x03'"


def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
    # {preset} marks pressure setup for the Aerotech host runtime: presets
    # execute at the controller's START signal, before the initial toggles.
    if start:
        return f"\n\r{{preset}}{port}.write(eval(setpress({pressure:g})))"
    insert = ""
    return f"\n\r{insert}{port}.write({_setpress(pressure)})"


def _toggle_cmd(port: str, start: bool) -> str:
    if start:
        return f"\n\r{{preset}}{port}.write(eval(togglepress()))"
    insert = ""
    return f"\n\r{insert}{port}.write({_togglepress()})"


def _valve_cmd(valve: int, command: int) -> str:
    return f"\n{{aux_command}}WAGO_ValveCommands({valve}, {command})\n"


def _coord(value: float) -> str:
    """Format a coordinate in fixed-point notation, never scientific.

    Python's repr writes small floats as e.g. "-5.1e-08", which G-code axis
    parsers (including this project's viewer) misread as "-5.1".
    """
    text = f"{float(value):.6f}".rstrip("0")
    if text.endswith("."):
        text += "0"
    if text in ("-0.0", "-0"):
        return "0.0"
    return text


def write_gcode_file(
    gcode_path: Path,
    gcode_list: list[dict],
    pressure: float,
    valve: int,
    port: int,
    increase_pressure_per_layer: float,
    pressure_ramp_enabled: bool,
    all_g1: bool,
    emit_pressure_commands: bool = True,
) -> None:
    """Write the move list as a G-code file.

    `emit_pressure_commands` gates EVERY pressure command (preset, toggle,
    per-layer ramp, closing toggle): the pressure regulator is a PORT
    device, so when several shapes share a serial port only ONE of their
    files may own it — the print host compiles all files onto one timeline,
    and duplicated toggles would flip the regulator on/off/on at start.
    """
    off_color = 0
    com_port = f"serialPort{port}"
    color_dict: dict[int, int] = {0: 100, 255: valve}

    setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
    pressure_on_lines = [_toggle_cmd(com_port, start=True)]
    pressure_off_lines = [_toggle_cmd(com_port, start=False)]

    pressure_cur = float(pressure)

    with open(gcode_path, "w") as f:
        f.write("G91\n")
        f.write(_valve_cmd(valve, 0))
        if emit_pressure_commands:
            for line in setpress_lines:
                f.write(f"{line}\n")
            for line in pressure_on_lines:
                f.write(f"{line}\n")

        pressure_next: str | None = None
        for i, move in enumerate(gcode_list):
            prev_color = gcode_list[i - 1]["Color"] if i > 0 else 0
            cur_color = move["Color"]
            if prev_color != cur_color:
                if cur_color == off_color:
                    f.write(_valve_cmd(color_dict[prev_color], 0))
                else:
                    if prev_color == off_color:
                        f.write(_valve_cmd(color_dict[cur_color], 1))
                    else:
                        f.write(_valve_cmd(color_dict[cur_color], 1))
                        f.write(_valve_cmd(color_dict[prev_color], 0))

            # When all_g1 is set, every move is emitted as G1 regardless of
            # valve state; the valve commands still mark print vs travel.
            move_type = "G1" if (all_g1 or cur_color != off_color) else "G0"
            if "Z" in move:
                line = (
                    f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
                    f"Z{_coord(move['Z'])} ; Color {move['Color']}"
                )
                if pressure_ramp_enabled and emit_pressure_commands:
                    pressure_cur += increase_pressure_per_layer
                    pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
                else:
                    pressure_next = None
            else:
                line = (
                    f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
                    f"; Color {move['Color']}"
                )
                pressure_next = None

            f.write(f"{line}\n")
            if pressure_next is not None:
                f.write(f"{pressure_next}\n")
                pressure_next = None

        f.write(_valve_cmd(valve, 0))
        if emit_pressure_commands:
            for line in pressure_off_lines:
                f.write(f"{line}\n")


def generate_vector_gcode(
    shape: LayerStack,
    *,
    shape_name: str,
    pressure: float,
    valve: int,
    port: int,
    fil_width: float,
    layer_height: float | None = None,
    raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
    motion: LayerStack | None = None,
    contour_sources: list[ContourSource] | None = None,
    active_contour_owner: int | None = None,
    infill: float = 1.0,
    motion_infill_fractions: list[float] | None = None,
    emit_pressure_commands: bool = True,
    increase_pressure_per_layer: float = 0.1,
    pressure_ramp_enabled: bool = True,
    all_g1: bool = False,
    lead_in_enabled: bool = False,
    lead_in_length: float = 5.0,
    lead_in_clearance: float = 5.0,
    lead_in_lines: int = 3,
    lead_in_direction: str = LEAD_IN_DIRECTION_LEFT,
    lead_in_dispense: bool = True,
    wall_sources: list[LayerStack] | None = None,
    origin_sink: dict | None = None,
    output_dir: str | Path | None = None,
) -> Path:
    """Generate G-code for one sliced shape.

    Without `motion`, the shape's own layers drive both the nozzle path and
    the valve. With `motion` (the combined reference stack), the nozzle
    follows the shared reference path while the valve opens only inside this
    shape's own geometry, aligned into the reference frame — so parallel
    heads share one motion but each dispenses only its own shape.

    `wall_sources` (all shapes in the job, whole shapes only) matters for
    the Circle Spiral under shared motion: every shape's own wall radius
    joins the ONE shared ring set, so each shape keeps a smooth complete
    outer circle. Pass the SAME list to every shape's generation call.

    `motion_infill_fractions` lists EVERY shape's infill fraction (again the
    same list for every call): raster lines/rings that no head dispenses on
    are dropped from the shared motion instead of swept valve-off. When
    omitted, this shape's own fraction bounds its motion.
    """
    if shape is None or not shape.layers:
        raise ValueError("The shape has no sliced layers to generate G-code from.")
    if fil_width <= 0:
        raise ValueError("Filament width must be greater than zero.")

    raster_pattern = _normalize_raster_pattern(raster_pattern)
    if layer_height is None:
        layer_height = shape.layer_height

    if motion is not None:
        if not motion.layers:
            raise ValueError("The reference stack has no layers for motion.")
        motion_layers = motion.layers
        valve_layers = align_stack_to(shape, motion, len(motion.layers))
        contour_reference = motion
    else:
        motion_layers = shape.layers
        valve_layers = shape.layers
        contour_reference = None

    contour_layers = build_contour_layers(
        contour_sources,
        len(motion_layers),
        reference=contour_reference,
    )

    # Anchor the raster scan grid (and the diagonal-raster pivot) to the
    # motion stack's frame (a split piece's frame is its parent shape's
    # bounds) so lines stack across layers and stay on one continuous grid
    # across split pieces.
    frame_stack = motion if motion is not None else shape
    if frame_stack.scan_frame is not None:
        scan_frame = frame_stack.scan_frame
    else:
        (frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
        scan_frame = (frame_x_min, frame_y_min, frame_x_max, frame_y_max)

    # Circle Spiral under shared motion (whole shapes): rings centre on the
    # reference ALIGN centre (each shape is concentric with it) and every
    # shape's outermost-fitting grid ring joins the shared ring set, so each
    # shape keeps a complete, uniformly spaced outer circle.
    extra_wall_radii = None
    ring_center = None
    if (
        raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL
        and motion is not None
        and motion.scan_frame is None
        and shape.scan_frame is None
    ):
        if motion.align_center is not None:
            ring_center = motion.align_center
        else:
            ring_center = (
                (scan_frame[0] + scan_frame[2]) / 2.0,
                (scan_frame[1] + scan_frame[3]) / 2.0,
            )
        n_layers = len(motion_layers)
        extra_wall_radii = [[] for _ in range(n_layers)]
        sources = [
            source
            for source in (wall_sources or [])
            if source is not None and source.layers and source.scan_frame is None
        ]
        if sources:
            for source in sources:
                aligned = align_stack_to(source, motion, n_layers)
                for index in range(n_layers):
                    wall = circle_wall_radius(
                        aligned[index], ring_center[0], ring_center[1], fil_width
                    )
                    if wall is not None:
                        extra_wall_radii[index].append(wall)
        else:
            # No source list: at least this shape's own outer ring.
            for index in range(n_layers):
                wall = circle_wall_radius(
                    valve_layers[index], ring_center[0], ring_center[1], fil_width
                )
                if wall is not None:
                    extra_wall_radii[index].append(wall)

    gcode_list, toolpath_origin = plan_layer_moves(
        motion_layers,
        valve_layers,
        fil_width,
        float(layer_height),
        raster_pattern,
        contour_layers,
        active_contour_owner,
        shared_motion=motion is not None,
        scan_frame=scan_frame,
        infill_fraction=max(0.0, min(1.0, float(infill))),
        extra_wall_radii=extra_wall_radii,
        ring_center=ring_center,
        motion_infill_fractions=(
            [max(0.0, min(1.0, float(fraction))) for fraction in motion_infill_fractions]
            if motion_infill_fractions is not None
            else None
        ),
    )

    # World anchor: the toolpath origin expressed in the shape's own frame.
    # With reference motion the geometry was translated by the centering
    # delta, so subtract it to get back to the shape's coordinates. It is
    # handed back through `origin_sink` (NOT written into the G-code — the
    # printed file stays free of metadata): the app stores it on the shape
    # record for Auto Align Split Parts and the visualizations.
    if motion is not None:
        delta_x, delta_y = _centering_delta(shape, motion)
    else:
        delta_x = delta_y = 0.0
    path_origin = (toolpath_origin[0] - delta_x, toolpath_origin[1] - delta_y)
    if origin_sink is not None:
        origin_sink["path_origin"] = path_origin

    # A shape that opts out of the lead-in still TRAVELS the purge patch when
    # motion is shared (all heads must move identically) but keeps its valve
    # shut; printing solo, it skips the lead-in moves entirely.
    lead_in = _lead_in_moves(
        lead_in_enabled and (lead_in_dispense or motion is not None),
        lead_in_length,
        lead_in_clearance,
        lead_in_lines,
        fil_width,
        255 if lead_in_dispense else 0,
        0,
        direction=lead_in_direction,
    )
    if lead_in:
        gcode_list = [*lead_in, *gcode_list]

    if output_dir is None:
        output_dir = Path(tempfile.mkdtemp(prefix="vector_gcode_"))
    else:
        output_dir = Path(output_dir)
        output_dir.mkdir(parents=True, exist_ok=True)

    gcode_path = output_dir / f"{shape_name}_gcode.txt"
    write_gcode_file(
        gcode_path,
        gcode_list,
        pressure=float(pressure),
        valve=int(valve),
        port=int(port),
        increase_pressure_per_layer=float(increase_pressure_per_layer),
        pressure_ramp_enabled=bool(pressure_ramp_enabled),
        all_g1=bool(all_g1),
        emit_pressure_commands=bool(emit_pressure_commands),
    )
    return gcode_path