Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ venv/
ENV/
env.bak/
venv.bak/
uv.lock

# Spyder project settings
.spyderproject
Expand Down
11 changes: 10 additions & 1 deletion bin/colab_tutorials.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@
"lab_four_axis_demo.ipynb",
"lab_five_axis_demo.ipynb",
"lab_stl_output.ipynb",
"lab_3mf_output.ipynb"]
"lab_3mf_output.ipynb",
"infinaxis_4axis_demo.ipynb",
"infinaxis_5axis_demo.ipynb",
"infinaxis_controls.ipynb",
"infinaxis_custom_axes.ipynb",
"infinaxis_xyz_geom.ipynb"
]

notebook_addresses = ["../tutorials/" +
notebook_name for notebook_name in notebook_names]
Expand All @@ -34,6 +40,8 @@
new_import_5ax = "if 'google.colab' in str(get_ipython()):\\n !pip install git+https://github.com/FullControlXYZ/fullcontrol --quiet\\n" + old_import_5ax
old_import_5ax2 = "import lab.fullcontrol.fiveaxisC0B1 as fc5"
new_import_5ax2 = "if 'google.colab' in str(get_ipython()):\\n !pip install git+https://github.com/FullControlXYZ/fullcontrol --quiet\\n" + old_import_5ax2
old_import_infinaxis = "import lab.fullcontrol.infinaxis as fci"
new_import_infinaxis = "if 'google.colab' in str(get_ipython()):\\n !pip install git+https://github.com/FullControlXYZ/fullcontrol --quiet\\n" + old_import_infinaxis

string_to_delete = 'links will work in vscode, jupyter lab, etc. - the notebooks can also be accessed [online](https://github.com/FullControlXYZ/fullcontrol/tree/master/tutorials) and run in google colab'

Expand All @@ -55,6 +63,7 @@
content_string = content_string.replace(old_import_5ax2, new_import_5ax2)
else:
content_string = content_string.replace(old_import, new_import)
content_string = content_string.replace(old_import_infinaxis, new_import_infinaxis)

if 'contents.ipynb' in notebook_address:
content_string = content_string.replace(string_to_delete, '')
Expand Down
7 changes: 7 additions & 0 deletions lab/fullcontrol/fiveaxis.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
"""Fixed-axis wrapper retained for existing examples.

New work may be better served by lab.fullcontrol.infinaxis, which is intended
to express equivalent dynamic axis setups. Exact gcode equivalence is still
being checked.
"""

from lab.fullcontrol.multiaxis.combinations.gcode_and_visualize.XYZBC.common import *
7 changes: 7 additions & 0 deletions lab/fullcontrol/fiveaxisC0B1.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
"""Fixed-axis wrapper retained for existing examples.

New work may be better served by lab.fullcontrol.infinaxis, which is intended
to express equivalent dynamic axis setups. Exact gcode equivalence is still
being checked.
"""

from lab.fullcontrol.multiaxis.combinations.gcode_and_visualize.XYZC0B1.common import *
7 changes: 7 additions & 0 deletions lab/fullcontrol/fouraxis.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
"""Fixed-axis wrapper retained for existing examples.

New work may be better served by lab.fullcontrol.infinaxis, which is intended
to express equivalent dynamic axis setups. Exact gcode equivalence is still
being checked.
"""

from lab.fullcontrol.multiaxis.combinations.gcode_and_visualize.XYZB.common import *
7 changes: 7 additions & 0 deletions lab/fullcontrol/infinaxis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Infinaxis Scope

`infinaxis` is currently set up for systems with normal XYZ linear axes, plus rotational axes after those linear axes at the end of the kinematic chain.

The inverse kinematics are calculated on that basis. This is useful because it explicitly works through nozzle offset from the rotation axes and part movement caused by rotation. The part-movement case is especially awkward: the effective offset depends on the exact current position within the part (distance from rotation axis), so it changes continuously as the toolpath moves.

Tutorial notebooks demonstrate how infinaxis works, and can be found [here](https://github.com/FullControlXYZ/fullcontrol/tree/master/tutorials/README.md)
1 change: 1 addition & 0 deletions lab/fullcontrol/infinaxis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from lab.fullcontrol.infinaxis.common import *
279 changes: 279 additions & 0 deletions lab/fullcontrol/infinaxis/_plot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import numpy as np
import plotly.graph_objects as go

from fullcontrol.visualize.plotly import generate_mesh
from fullcontrol.visualize.tube_mesh import CylindersMesh, FlowTubeMesh


def generate_single_path_vase_lod_surface_arrays(
path,
center_xy=None,
points_per_turn: int = 128,
turn_stride: int = 2,
color_mode: str = "none",
):
points = np.asarray([path.xvals, path.yvals, path.zvals], dtype=np.float32).T

if len(points) < 4:
return None, None, None, None

good = np.ones(len(points), dtype=bool)
dups = np.all(np.diff(points, axis=0) == 0, axis=1)
good[1:] = ~dups
points = points[good]

if len(points) < 4:
return None, None, None, None

source_color_scalar = None

if color_mode == "path":
if getattr(path, "colors", None) is not None and len(path.colors) > 0:
colors_arr = np.asarray(path.colors, dtype=np.float32)

if len(colors_arr) == len(good):
colors_arr = colors_arr[good]

if len(colors_arr) == len(points):
source_color_scalar = (
0.299 * colors_arr[:, 0]
+ 0.587 * colors_arr[:, 1]
+ 0.114 * colors_arr[:, 2]
).astype(np.float32)

if center_xy is None:
cx = np.float32(0.5 * (np.min(points[:, 0]) + np.max(points[:, 0])))
cy = np.float32(0.5 * (np.min(points[:, 1]) + np.max(points[:, 1])))
else:
cx, cy = center_xy

theta = np.unwrap(np.arctan2(points[:, 1] - cy, points[:, 0] - cx)).astype(np.float32)

if theta[-1] < theta[0]:
theta = -theta

turn_coord = ((theta - theta[0]) / np.float32(2.0 * np.pi)).astype(np.float32)

monotonic_good = np.concatenate([[True], np.diff(turn_coord) > 1e-10])
turn_coord = turn_coord[monotonic_good]
points = points[monotonic_good]

if source_color_scalar is not None:
source_color_scalar = source_color_scalar[monotonic_good]

if len(points) < 4:
return None, None, None, None

first_complete_turn = int(np.ceil(turn_coord[0]))
last_complete_turn = int(np.floor(turn_coord[-1])) - 1

if last_complete_turn <= first_complete_turn:
return None, None, None, None

all_turns = np.arange(first_complete_turn, last_complete_turn + 1, dtype=np.float32)

selected_turns = all_turns[::max(1, int(turn_stride))]

if selected_turns[-1] != all_turns[-1]:
selected_turns = np.append(selected_turns, all_turns[-1]).astype(np.float32)

if len(selected_turns) < 2:
return None, None, None, None

phases = np.linspace(
0.0,
1.0,
points_per_turn + 1,
endpoint=True,
dtype=np.float32,
)

targets = selected_turns[:, None] + phases[None, :]
targets_flat = targets.ravel()

x_grid = np.interp(targets_flat, turn_coord, points[:, 0]).reshape(targets.shape).astype(np.float32)
y_grid = np.interp(targets_flat, turn_coord, points[:, 1]).reshape(targets.shape).astype(np.float32)
z_grid = np.interp(targets_flat, turn_coord, points[:, 2]).reshape(targets.shape).astype(np.float32)

if color_mode == "path" and source_color_scalar is not None:
color_grid = np.interp(
targets_flat,
turn_coord,
source_color_scalar,
).reshape(targets.shape).astype(np.float32)

elif color_mode == "height":
color_grid = z_grid

elif color_mode == "turn":
color_grid = targets.astype(np.float32)

else:
color_grid = np.zeros_like(z_grid, dtype=np.float32)

return x_grid, y_grid, z_grid, color_grid


def generate_single_path_vase_lod_surface_trace(
path,
center_xy=None,
points_per_turn: int = 128,
turn_stride: int = 2,
color_mode: str = "none",
colorscale="Viridis",
showscale: bool = False,
opacity: float = 1.0,
):
x, y, z, surfacecolor = generate_single_path_vase_lod_surface_arrays(
path,
center_xy=center_xy,
points_per_turn=points_per_turn,
turn_stride=turn_stride,
color_mode=color_mode,
)

if x is None:
return None

return go.Surface(
x=x,
y=y,
z=z,
surfacecolor=surfacecolor,
colorscale=colorscale,
showscale=showscale,
opacity=opacity,
hoverinfo="skip",
contours=dict(
x=dict(show=False),
y=dict(show=False),
z=dict(show=False),
),
showlegend=False,
)


def fig_plot(data, controls):
fig = go.Figure()

if controls.tube_type is not None:
Mesh = {"flow": FlowTubeMesh, "cylinders": CylindersMesh}[controls.tube_type]
else:
Mesh = FlowTubeMesh

max_width = 0

for path in data.paths:
colors_now = [f"rgb({color[0]*255:.2f}, {color[1]*255:.2f}, {color[2]*255:.2f})" for color in path.colors]
linewidth_now = controls.line_width * 2 if path.extruder.on == True else controls.line_width * 0.5

if path.widths:
max_width = max(max_width, max(path.widths))

if path.extruder.on and controls.style == "vase_surface":
surface_trace = generate_single_path_vase_lod_surface_trace(
path,
points_per_turn=96,
turn_stride=3,
color_mode="height",
colorscale="viridis",
showscale=False,
opacity=1.0,
)

if surface_trace is not None:
fig.add_trace(surface_trace)

elif path.extruder.on and controls.style == "tube":
sides, rounding_strength, flat_sides = controls.tube_sides, 0.4, False
mesh = generate_mesh(path, linewidth_now, Mesh, sides, rounding_strength, flat_sides, colors_now)
fig.add_trace(mesh.to_Mesh3d(colors=colors_now))

elif not controls.hide_travel or path.extruder.on:
fig.add_trace(go.Scatter3d(
mode="lines",
x=path.xvals,
y=path.yvals,
z=path.zvals,
showlegend=False,
line=dict(width=linewidth_now, color=colors_now),
))

bounding_box_size = max(
data.bounding_box.maxx - data.bounding_box.minx,
data.bounding_box.maxy - data.bounding_box.miny,
data.bounding_box.maxz - min(0, data.bounding_box.minz),
)
bounding_box_size += 0.002
bounding_box_size += max_width

annotations_pts = []
annotations = []
if controls.hide_annotations == False and not controls.neat_for_publishing:
for annotation in data.annotations:
x, y, z = (annotation[axis] for axis in "xyz")
annotations_pts.append([x, y, z])
annotations.append(dict(
showarrow=False,
x=x,
y=y,
z=z,
text=annotation["label"],
yshift=10,
))
xs, ys, zs = zip(*annotations_pts) if annotations_pts else [[]] * 3
fig.add_trace(go.Scatter3d(mode="markers", x=xs, y=ys, z=zs, showlegend=False, marker=dict(size=2, color="red")))

midx, midy, midz = (getattr(data.bounding_box, f"mid{axis}") for axis in "xyz")
offset = 0.001
offset_both_sides = 2 * offset
for (x, y, z) in annotations_pts:
if x < midx - bounding_box_size / 2 + offset:
bounding_box_size = 2 * (midx - x) + offset_both_sides
if x > midx + bounding_box_size / 2 - offset:
bounding_box_size = 2 * (x - midx) + offset_both_sides
if y < midy - bounding_box_size / 2 + offset:
bounding_box_size = 2 * (midy - y) + offset_both_sides
if y > midy + bounding_box_size / 2 - offset:
bounding_box_size = 2 * (y - midy) + offset_both_sides
if z < midz - bounding_box_size / 2 + offset:
bounding_box_size = 2 * (midz - z) + offset_both_sides
if z > midz + bounding_box_size / 2 - offset:
bounding_box_size = 2 * (z - midz) + offset_both_sides

relative_centre_z = 0.5 * data.bounding_box.rangez / bounding_box_size
camera_centre_z = -0.5 + relative_centre_z
camera = dict(
eye=dict(x=-0.5 / controls.zoom, y=-1 / controls.zoom, z=-0.5 + 0.5 / controls.zoom),
center=dict(x=0, y=0, z=camera_centre_z),
)
fig.update_layout(
template="plotly_dark",
paper_bgcolor="black",
scene_aspectmode="cube",
scene=dict(
annotations=annotations,
xaxis=dict(
backgroundcolor="black",
nticks=10,
range=[data.bounding_box.midx - bounding_box_size / 2, data.bounding_box.midx + bounding_box_size / 2],
),
yaxis=dict(
backgroundcolor="black",
nticks=10,
range=[data.bounding_box.midy - bounding_box_size / 2, data.bounding_box.midy + bounding_box_size / 2],
),
zaxis=dict(backgroundcolor="black", nticks=10, range=[min(0, data.bounding_box.minz), bounding_box_size]),
),
scene_camera=camera,
width=800,
height=500,
margin=dict(l=10, r=10, b=10, t=10, pad=4),
)
if controls.hide_axes or controls.neat_for_publishing:
for axis in ["xaxis", "yaxis", "zaxis"]:
fig.update_layout(scene={axis: dict(showgrid=False, zeroline=False, visible=False)})
if controls.neat_for_publishing:
fig.update_layout(width=500, height=500)

return fig
25 changes: 25 additions & 0 deletions lab/fullcontrol/infinaxis/axis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import Optional
from pydantic import BaseModel
from fullcontrol import Point

class Axis(BaseModel):
name: Optional[str] = None # Used for the output gcode. And sets type if type not given
type: Optional[str] = None # 'A', 'B', 'C', 'X', 'Y', 'Z'
active: float = 0 # current position (for rotational axes, the angle of rotation in degrees. for linear axes, the position in mm.)
orientation: float = 1 # 1 or -1, 1 means the axis follows mathematical convention (either positive/counterclockwise rotation or fits the right hand rule for linear axes)
offset: Optional[Point] = None # the offset in mm in x,y,z from the previous axis in the chain.

def __init__(self, **data):
super().__init__(**data)

if self.name is not None and self.name.upper() in ["X", "Y", "Z"]:
raise ValueError('Axis.name cannot be "X", "Y", or "Z"; use Axis.type for linear kinematics with a different name')

if self.type is None:
if self.name is not None and self.name in ["A", "B", "C"]:
self.type = self.name
else:
raise ValueError("Cannot set axis type based on name")

if self.offset is None:
self.offset = Point(x=0, y=0, z=0)
Loading