Copy this entire script into the svgEXT DAT inside the .tox component.

Implements: Issue 1 (precision), Issue 2 (grouping), Issue 3 (Inkscape/Illustrator compatibility), Issue 4 (landscape scaling), Issue 5 Phase 1 (camera xOffset), Issue 5 Phase 2 (correct perspective projection), Issue 7 (SVG profile), Issue 8 (stale state - OOP refactor), pretty printing, stroke-width rounding, mm/in unit switch, Issue 9 (long polyline splitting for Illustrator compat)

Issue 8 Update: All global coordinate/scaling variables moved to instance variables. Benefits: eliminates stale state bugs, allows multiple instances, thread-safe, testable, no module reload issues. Call _refresh_state() or save() to update values.


"""SVG Write in TouchDesigner Class

Authors | matthew ragan
matthew ragan.com

v2 Additional features/fixes added by:
kris northern - IG @krisnorthern

v3 Bug fixes and optimizations:
  - Issue 1: Coordinate precision reduced to 2 decimal places
    (matches AxiDraw 12.5um step size, ~40-50% file size reduction)
  - Issue 2: SVG grouping - all geometry wrapped in <g id="sop-export">
  - Issue 3: Inkscape/Illustrator compatibility - explicit mm units,
    space-separated viewBox string (not comma-separated)
  - Issue 4: Landscape/portrait scaling - single scalar = max(W,H)
    (Trace SOP normalizes longer axis to -0.5..0.5, shorter scales)
  - Issue 5 Phase 1: Camera xOffset bug fix (was yOffset for both axes)
  - Issue 7: SVG profile changed from 'tiny' to 'full'
  - Issue 8: Global variable stale state - moved to instance variables (OOP)
  - Pretty printing: SVG output is now indented/formatted XML
  - stroke-width rounded to PRECISION decimal places
  - Unit switch: supports mm or inches input (Unitmode parameter)
  - Issue 9: Long polyline splitting for Adobe Illustrator compatibility
"""

import svgwrite
import numpy as np
import math
import webbrowser

# Module-level constants (initialized once on import)
PRECISION = 2
MM_PER_INCH = 25.4
MAX_POINTS_PER_POLYLINE = 5000

def round_point(x, y):
    """Round a coordinate pair to plotter-meaningful precision (Issue 1)."""
    return (round(x, PRECISION), round(y, PRECISION))

def split_polyline(points, max_pts=MAX_POINTS_PER_POLYLINE):
    """Split a point list into chunks for Illustrator compatibility (Issue 9).

    Consecutive chunks overlap by one point so the rendered path
    appears continuous (the last point of chunk N == the first point
    of chunk N+1).

    Args
    ----
    points : list of tuples
        Point coordinates
    max_pts : int
        Maximum points per polyline

    Returns
    -------
    list of lists
        Chunks of points. If len(points) <= max_pts the original list
        is returned as the sole element.
    """
    if len(points) <= max_pts:
        return [points]
    chunks = []
    start = 0
    while start < len(points):
        end = min(start + max_pts, len(points))
        chunks.append(points[start:end])
        if end == len(points):
            break
        # Overlap by one point for visual continuity
        start = end - 1
    return chunks

class Soptosvg:
    """Handle writing SVGs from SOPs in TouchDesigner.

    All coordinate and scaling state is instance-specific (no globals).
    This allows multiple instances to exist independently without
    interfering with each other.
    """

    def __init__(self):
        """Initialize the Soptosvg instance with TouchDesigner parameters."""
        self.polylinesop = parent.svg.par.Polylinesop
        self.polygonsop = parent.svg.par.Polygonsop
        self.camera = parent.svg.par.Camera
        self.use_camera = parent.svg.par.Usecamera
        self.svgtype = parent.svg.par.Svgtype
        self.filepath = "{dir}/{file}.svg"
        self.axidocumentation = "<http://wiki.evilmadscientist.com/AxiDraw>"
        self.axipdf = (
            "<http://cdn.evilmadscientist.com/wiki/axidraw/software/>"
            "AxiDraw_V33.pdf"
        )
        self.svgwritedocumentation = (
            "<http://svgwrite.readthedocs.io/en/latest/svgwrite.html>"
        )

        # Issue 8: Instance variables for canvas/scaling state
        # These are refreshed when needed (in save() or via _refresh_state())
        self.canvas_width = None
        self.canvas_height = None
        self.x_offset = None
        self.y_offset = None
        self.stroke_width = None
        self.stroke_r = None
        self.stroke_g = None
        self.stroke_b = None
        self.zoom = 1
        self.scalar = None
        self.scalar_x = None
        self.scalar_y = None

        print("Sop to SVG Initialized")

    def _get_unitmode(self):
        """Return the current unit mode ('mm' or 'in')."""
        try:
            return parent.svg.par.Unitmode.eval()
        except:
            return 'mm'

    def _canvas_mm(self):
        """Return (width_mm, height_mm).

        Canvasmm1/2 are always in mm (TD expressions handle the
        conversion from paper size).
        """
        w = float(parent.svg.par.Canvasmm1)
        h = float(parent.svg.par.Canvasmm2)
        return (w, h)

    def _canvas_output(self):
        """Return (width, height) in the active output unit for SVG."""
        w, h = self._canvas_mm()
        if self._get_unitmode() == 'in':
            w = round(w / MM_PER_INCH, PRECISION)
            h = round(h / MM_PER_INCH, PRECISION)
        else:
            w = round(w, PRECISION)
            h = round(h, PRECISION)
        return (w, h)

    def _unit_suffix(self):
        """Return the SVG unit suffix for the active unit mode."""
        return 'in' if self._get_unitmode() == 'in' else 'mm'

    def _refresh_state(self):
        """Refresh all coordinate and scaling state from current parameters.

        Issue 8: This replaces the old global variable refresh pattern.
        Called at the start of save() to ensure values are current.
        """
        # Get canvas dimensions in the active output unit (mm or in).
        # All SVG coordinates, offsets, and scalars use this unit.
        self.canvas_width, self.canvas_height = self._canvas_output()

        self.x_offset = round(self.canvas_width / 2, PRECISION)
        self.y_offset = round(self.canvas_height / 2, PRECISION)
        self.stroke_width = round(
            float(op('strokeWidth')['width']) * 0.30, PRECISION
        )
        # Scale stroke-width to output unit when using inches
        if self._get_unitmode() == 'in':
            self.stroke_width = round(
                self.stroke_width / MM_PER_INCH, PRECISION + 2
            )
        self.stroke_r = int(float(parent.svg.par.Rgbr * 255))
        self.stroke_g = int(float(parent.svg.par.Rgbg * 255))
        self.stroke_b = int(float(parent.svg.par.Rgbb * 255))
        self.zoom = 1
        # Issue 4: Trace SOP normalizes the LONGER axis to -0.5..0.5
        # and scales the shorter axis proportionally. A single scalar
        # based on the longer dimension maps both axes correctly.
        self.scalar = max(self.canvas_width, self.canvas_height) * self.zoom
        self.scalar_x = self.scalar
        self.scalar_y = self.scalar

    def world_to_cam(self, old_p):
        """Convert a world-space position to SVG canvas coordinates.

        Converts via the camera's view and projection matrices.
        Works for both orthographic and perspective cameras.
        Orthographic projections produce w=1 so the perspective
        divide is a no-op.

        Args
        ----
        old_p (tdu.Position) : world-space position

        Returns
        -------
        tuple : (x, y) in canvas units (mm or in), rounded to PRECISION
        """
        camera = op(self.camera.eval())
        view = camera.transform()
        view.invert()
        pers = camera.projection(
            float(parent.svg.par.Canvasmm1),
            float(parent.svg.par.Canvasmm2),
        )

        # World → View → NDC (-1..1)
        # TD automatically performs the perspective divide (by w)
        # when multiplying a projection matrix by a tdu.Position.
        ndc = pers * (view * old_p)

        # NDC → SVG canvas coordinates
        svg_x = (ndc.x + 1.0) * 0.5 * self.canvas_width
        svg_y = (1.0 - ndc.y) * 0.5 * self.canvas_height

        return round_point(svg_x, svg_y)

    def canvas_size(self):
        """Return the dimensions of the canvas.

        Returns
        -------
        tuple : (width, height) in the active unit mode (mm or in)
        """
        # Issue 3: Round dimensions and use explicit units for
        # correct display in both Inkscape (96 DPI) and Illustrator (72 DPI)
        w, h = self._canvas_output()
        suffix = self._unit_suffix()
        return (f'{w}{suffix}', f'{h}{suffix}')

    def viewbox_size(self):
        """Return viewBox size as space-separated string.

        Issue 3: Space-separated (not comma-separated) so both Inkscape
        and Illustrator interpret correctly. Uses output units to match
        canvas_size dimensions.
        """
        w, h = self._canvas_output()
        return f'0 0 {w} {h}'

    def save_polyline(self, path, pline):
        """Save an unclosed polyline as SVG.

        Args
        ----
        path (str) : output file path
        pline : polyline SOP
        """
        prims = pline.prims
        canvassize = self.canvas_size()
        viewbox_dims = self.viewbox_size()
        # Issue 7: Changed profile from 'tiny' to 'full'
        dwg = svgwrite.Drawing(
            path, profile='full', size=canvassize, viewBox=viewbox_dims
        )
        # Issue 2: Wrap geometry in a group
        group = dwg.g(id='sop-export')

        for item in prims:
            if self.use_camera:
                new_points = [self.world_to_cam(vert.point.P) for vert in item]
            else:
                # Issue 1: Round coordinates. Issue 4: Single scalar =
                # max(W,H) for correct landscape/portrait scaling.
                new_points = [
                    round_point(
                        (vert.point.x * self.scalar) + self.x_offset,
                        (-vert.point.y * self.scalar) + self.y_offset,
                    )
                    for vert in item
                ]
            # Issue 9: Split long polylines for Illustrator compatibility
            for chunk in split_polyline(new_points):
                new_poly = dwg.polyline(
                    points=chunk,
                    stroke=svgwrite.rgb(
                        self.stroke_r, self.stroke_g, self.stroke_b
                    ),
                    stroke_width=self.stroke_width,
                    fill='none',
                )
                group.add(new_poly)

        dwg.add(group)  # Issue 2: Add group to drawing
        dwg.save(pretty=True)

    def save_polygon(self, path, pgon):
        """Save a closed polygon as SVG.

        Args
        ----
        path (str) : output file path
        pgon : polygon SOP
        """
        prims = pgon.prims
        canvassize = self.canvas_size()
        viewbox_dims = self.viewbox_size()
        # Issue 7: Changed profile from 'tiny' to 'full'
        dwg = svgwrite.Drawing(
            path, profile='full', size=canvassize, viewBox=viewbox_dims
        )
        # Issue 2: Wrap geometry in a group
        group = dwg.g(id='sop-export')

        for item in prims:
            if self.use_camera:
                new_points = [self.world_to_cam(vert.point.P) for vert in item]
            else:
                # Issue 1: Round coordinates. Issue 4: Single scalar =
                # max(W,H) for correct landscape/portrait scaling.
                new_points = [
                    round_point(
                        (vert.point.x * self.scalar) + self.x_offset,
                        (-vert.point.y * self.scalar) + self.y_offset,
                    )
                    for vert in item
                ]

            # Issue 9: Split long polylines for Illustrator compatibility
            for chunk in split_polyline(new_points):
                new_poly = dwg.polyline(
                    points=chunk,
                    stroke=svgwrite.rgb(
                        self.stroke_r, self.stroke_g, self.stroke_b
                    ),
                    stroke_width=self.stroke_width,
                    fill='none',
                )
                group.add(new_poly)

        dwg.add(group)  # Issue 2: Add group to drawing
        dwg.save(pretty=True)

    def save_polygon_and_polyline(self, path, pline, pgon):
        """Save both polygon and polyline geometry into a single SVG.

        Args
        ----
        path (str) : output file path
        pline : polyline SOP
        pgon : polygon SOP
        """
        pgon_prims = pgon.prims
        pline_prims = pline.prims
        canvassize = self.canvas_size()
        viewbox_dims = self.viewbox_size()
        # Issue 7: Changed profile from 'tiny' to 'full'
        dwg = svgwrite.Drawing(
            path, profile='full', size=canvassize, viewBox=viewbox_dims
        )
        # Issue 2: Wrap geometry in a group
        group = dwg.g(id='sop-export')

        # Process polygons
        for item in pgon_prims:
            if self.use_camera:
                new_points = [self.world_to_cam(vert.point.P) for vert in item]
            else:
                new_points = [
                    round_point(
                        (vert.point.x * self.scalar) + self.x_offset,
                        (-vert.point.y * self.scalar) + self.y_offset,
                    )
                    for vert in item
                ]

            for chunk in split_polyline(new_points):
                new_poly = dwg.polyline(
                    points=chunk,
                    stroke=svgwrite.rgb(
                        self.stroke_r, self.stroke_g, self.stroke_b
                    ),
                    stroke_width=self.stroke_width,
                    fill='none',
                )
                group.add(new_poly)

        # Process polylines
        for item in pline_prims:
            if self.use_camera:
                new_points = [self.world_to_cam(vert.point.P) for vert in item]
            else:
                new_points = [
                    round_point(
                        (vert.point.x * self.scalar) + self.x_offset,
                        (-vert.point.y * self.scalar) + self.y_offset,
                    )
                    for vert in item
                ]

            for chunk in split_polyline(new_points):
                new_poly = dwg.polyline(
                    points=chunk,
                    stroke=svgwrite.rgb(
                        self.stroke_r, self.stroke_g, self.stroke_b
                    ),
                    stroke_width=self.stroke_width,
                    fill='none',
                )
                group.add(new_poly)

        dwg.add(group)  # Issue 2: Add group to drawing
        dwg.save(pretty=True)

    def par_check(self, svg_type):
        """Validate that all required parameters are set.

        Args
        ----
        svg_type (str) : type of SVG to generate ('pline', 'pgon', 'both')

        Returns
        -------
        bool : True if parameters are valid, False otherwise
        """
        ready = False
        title = "We're off the RAILS!"
        message = (
            "Hey there, things don't look totally right.\\n"
            "Check on these parameters to make sure everything is "
            "in order:\\n{}"
        )
        buttons = ['okay']
        checklist = []

        # error handling for geometry permutations
        # handling polyline saving
        if self.svgtype == 'pline':
            if self.polylinesop is not None and op(self.polylinesop).isSOP:
                pass
            else:
                checklist.append('Missing Polygon SOP')

        # handling polygon saving
        elif self.svgtype == 'pgon':
            if self.polygonsop is not None and op(self.polygonsop).isSOP:
                pass
            else:
                checklist.append('Missing Polyline SOP')

        # handling combined objects - polyline and polygon saving
        elif self.svgtype == 'both':
            polyline = (
                self.polylinesop is not None
                and op(self.polylinesop).isSOP
            )
            polygon = (
                self.polygonsop is not None and op(self.polygonsop).isSOP
            )

            # both sops are present
            if polyline and polygon:
                pass
            # missing polyline sop
            elif polygon and not polyline:
                checklist.append('Missing Polyline SOP')
            # missing polygon sop
            elif polyline and not polygon:
                checklist.append('Missing Polygon SOP')
            # missing both polyline and polygon sops
            elif not polyline and not polygon:
                checklist.append('Missing Polygon SOP')
                checklist.append('Missing Polyline SOP')

        # handling to check for a directory path
        if parent.svg.par.Dir is None or parent.svg.par.Dir.val == '':
            checklist.append('Missing Directory Path')

        # handling to check for a file path
        if (
            parent.svg.par.Filename is None
            or parent.svg.par.Filename.val == ''
        ):
            checklist.append('Missing File name')

        # we're in the clear, everything is ready to go
        if len(checklist) == 0:
            ready = True
        # correctly format message for ui.messageBox and warn user
        else:
            ready = False
            message_checklist = '\\n'
            for item in checklist:
                message_checklist += f'     * {item}\\n'

            message = message.format(message_checklist)
            ui.messageBox(title, message, buttons=buttons)
        return ready

    def save(self):
        """Save SVG to disk.

        Based on settings in the tox's parameters, this method will utilize
        helper methods to correctly save out the file.
        """
        # Issue 8: Refresh state from current parameters
        self._refresh_state()

        # get the svg type
        svgtype = self.svgtype

        # start with par_check to see if we're ready to proceed.
        ready_to_continue = self.par_check(svgtype)

        if ready_to_continue:
            filepath = self.filepath.format(
                dir=parent.svg.par.Dir, file=parent.svg.par.Filename
            )

            # DEBUG: print scaling values and SOP coordinate ranges
            print("--- SVG Export Debug ---")
            print(f"  Unitmode: {self._get_unitmode()}")
            print(f"  Canvasmm1 (raw): {float(parent.svg.par.Canvasmm1)}")
            print(f"  Canvasmm2 (raw): {float(parent.svg.par.Canvasmm2)}")
            print(
                f"  CanvasWidth: {self.canvas_width}  "
                f"CanvasHeight: {self.canvas_height}"
            )
            print(
                f"  ScalarX: {self.scalar_x}  ScalarY: {self.scalar_y}  "
                f"Scalar: {self.scalar}"
            )
            print(
                f"  xOffset: {self.x_offset}  yOffset: {self.y_offset}"
            )
            print(f"  Canvas_size: {self.canvas_size()}")
            print(f"  viewBox: {self.viewbox_size()}")
            # Print SOP point coordinate ranges
            sop_ref = None
            if svgtype == 'pline' or svgtype == 'both':
                sop_ref = op(self.polylinesop)
            elif svgtype == 'pgon':
                sop_ref = op(self.polygonsop)
            if sop_ref is not None:
                all_x = [v.point.x for p in sop_ref.prims for v in p]
                all_y = [v.point.y for p in sop_ref.prims for v in p]
                if all_x:
                    print(
                        f"  SOP x range: {min(all_x):.6f} to "
                        f"{max(all_x):.6f}"
                    )
                    print(
                        f"  SOP y range: {min(all_y):.6f} to "
                        f"{max(all_y):.6f}"
                    )
                    print(
                        f"  SVG x range: {min(all_x)*self.scalar+self.x_offset:.2f} "
                        f"to {max(all_x)*self.scalar+self.x_offset:.2f}"
                    )
                    print(
                        f"  SVG y range: {min(all_y)*self.scalar+self.y_offset:.2f} "
                        f"to {max(all_y)*self.scalar+self.y_offset:.2f}"
                    )
            print("--- End Debug ---")

            if svgtype == 'pline':
                self.save_polyline(
                    path=filepath, pline=op(self.polylinesop)
                )

            elif svgtype == 'pgon':
                self.save_polygon(
                    path=filepath, pgon=op(self.polygonsop)
                )

            elif svgtype == 'both':
                self.save_polygon_and_polyline(
                    path=filepath,
                    pline=op(self.polylinesop),
                    pgon=op(self.polygonsop),
                )
            else:
                print("Woah... something is very wrong")

            print(filepath)
            print(self.polylinesop)
            print(self.svgtype)