GUI - Rhino - Radial Menu - Cross Platform

Hi @antoine, i’m using png images converted to Eto.Drawing.Bitmap for now.

@michaelvollrath, redrawing is really fast so performance wise this should be no issue. You can however layer two graphic objects and only use one of them to draw your highlight effects while keeping the other one unchanged.

_
c.

1 Like

Good point, that makes sense, I’ll proceed in that direction. Thanks!

@michaelvollrath, i’ve found something not so nice. Does the Python transparency hack example work on Windows using Rhino 7 for you ? I get an error but it works fine in Rhino 8 (Win & Mac).

It’s this error, translated from german:

Message: AllowsTransparency cannot be changed after displaying a Window or calling WindowInteropHelper.EnsureHandle.

_
c.

1 Like

That’s a bummer. I actually don’t have R7 so I can’t test sorry.

You need to add the styles when you load the plug-in. Then everything works fine on Rhino 7 Windows. If you attempt to load it from your panel’s constructor, it will throw this error.

Hi @mrhe, ok but i have no PlugIn, only a script. I’ll poke around a bit…

_
c.

Hello,

I’m wondering if someone can weigh in on “layering” in Eto. I’m attempting to add a simple pen outline in a separate function from my main drawing function and then combine these as a single bitmap.

Essentially I have the background “layer” drawing a radial array of circles that are the placeholder backgrounds for toolbar buttons.

Then, using vector math I am retrieving the current button based on the mouse direction so that the user can “flick select” a button down the road…

Right now I’m trying to simply highlight the currently selected button based on the “flick” by drawing an orange pen around it but I’m confusing myself on how to properly “layer” or combine said drawing elements in Eto.

I have a feeling I’m overcomplicating it…

Here’s the full code thus far, I would love anyone’s advice to weigh in, thank you!

Currently it’s setup to spawn the dialog on Middle Mouse Down and remove the dialog on Middle Mouse Up with the intention of the “flick” being all that is needed to select the toolbar button rather than having to hover over and click on said button explicitly.

But as I said, currently I’m just trying to understanding the Eto drawing and layering. Thanks for the help!

Code:

import Eto.Forms as forms
import Eto.Drawing as drawing
import System
import Rhino.UI
import math

class TransparentDialog(forms.Dialog[bool]):
    def __init__(self):
        self.Size = drawing.Size(300, 300)
        self.Padding = drawing.Padding(5)
        self.Opacity = 1  

        self.dialog_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)
        self.DrawCirclesOnBitmap(self.dialog_bitmap)

        # Add an extra bitmap for the highlighted circle
        self.highlight_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)
        
        self.image_view = forms.ImageView()
        self.image_view.Image = self.dialog_bitmap

        layout = forms.DynamicLayout()
        layout.AddRow(self.image_view)
        self.Content = layout

        # Variable to store the index of the circle "pointed to" by the mouse
        self.pointed_circle_index = None

        self.Title = "PIE Menu 0.1"

        # Add an extra bitmap for the highlighted circle
        self.highlight_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)

    def DrawCirclesOnBitmap(self, bitmap):
        circle_radius = 100
        num_circles = 8
        button_radius = 35

        graphics = drawing.Graphics(bitmap)
        circle_fill_color = drawing.Colors.White
        circle_border_color = drawing.Colors.Gray
        circ_brush = drawing.SolidBrush(circle_fill_color)
        border_pen = drawing.Pen(circle_border_color, 2)

        center_x = bitmap.Size.Width / 2
        center_y = bitmap.Size.Height / 2

        for i in range(num_circles):
            angle = (2 * math.pi / num_circles) * i
            circle_center_x = center_x + circle_radius * math.cos(angle)
            circle_center_y = center_y + circle_radius * math.sin(angle)
            circle_position = drawing.Point(circle_center_x - button_radius, circle_center_y - button_radius)
            circle_size = drawing.Size(2 * button_radius, 2 * button_radius)
            graphics.FillEllipse(circle_fill_color, drawing.Rectangle(circle_position, circle_size))
            graphics.DrawEllipse(border_pen, drawing.Rectangle(circle_position, circle_size))    

        graphics.Dispose()

class SampleTransparentEtoForm(forms.Form):
    def __init__(self):
        self.WindowStyle = forms.WindowStyle.None
        
        self.Styles.Add[forms.Panel]("transparent", self.MyFormStyler)
        self.Style = "transparent"
        
        self.AutoSize = False
        self.Resizable = True
        self.TopMost = True
        self.ShowActivated = False
        self.Size = drawing.Size(300, 300)
        self.Padding = drawing.Padding(20)
        self.MovableByWindowBackground = True

        self.close_button = forms.Button(Text="Close")
        self.close_button.Click += self.OnCloseButtonClick

        self.dialog = TransparentDialog()

        layout = forms.DynamicLayout()
        # layout.AddRow(self.close_button)
        layout.AddRow(self.dialog.Content)

        self.Content = layout

    def MyFormStyler(self, control):
        self.BackgroundColor = drawing.Colors.Transparent
        window = control.ControlObject
        if hasattr(window, "AllowsTransparency"):
            window.AllowsTransparency = True
        if hasattr(window, "Background"):
            brush = window.Background.Clone()
            brush.Opacity = 0
            window.Background = brush
        else:
            color = window.BackgroundColor
            window.BackgroundColor = color.FromRgba(0, 0, 0, 0)

    def OnCloseButtonClick(self, sender, e):
        # Rhino.RhinoApp.WriteLine("Closing dialog...")
        self.Close()

class MyMouseCallback(Rhino.UI.MouseCallback):
    def __init__(self):
        self.original_position = None
        self.form = None
        self.circle_centers = []  # List to store the center positions

    def OnMouseDown(self, e):    
        if e.Button == System.Windows.Forms.MouseButtons.Middle:
            self.original_position = Rhino.UI.MouseCursor.Location
            Rhino.RhinoApp.WriteLine("Mouse X: {}, Mouse Y: {}".format(self.original_position.X, self.original_position.Y))        
            # Rhino.RhinoApp.WriteLine("Initializing dialog...")
            
            self.form = SampleTransparentEtoForm()
            form_loc = drawing.Point(self.original_position.X, self.original_position.Y)
            self.form.Location = form_loc
            self.form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
            self.form.Show()

        else:
            Rhino.RhinoApp.WriteLine("Failure...")
            return

    def DrawCircleHighlight(self, circle_index, circle_centers):
        Rhino.RhinoApp.WriteLine("Drawing Circle Highlight...")
        Rhino.RhinoApp.WriteLine("Button Index: " + str(circle_index))
    
        if self.form and self.highlight_bitmap:
            Rhino.RhinoApp.WriteLine("Highlight Bitmap Exists...")
            graphics = drawing.Graphics(self.highlight_bitmap)
            highlight_pen = drawing.Pen(drawing.Colors.Orange, 6) # Highlight Pen
            highlight_radius = 38  # Larger radius for the highlight circle
            
            # Retrieve the center position based on the circle index
            circle_center_x, circle_center_y = circle_centers[circle_index]
            Rhino.RhinoApp.WriteLine(str(circle_centers[circle_index]))
            
            highlight_position = drawing.Point(circle_center_x - highlight_radius, circle_center_y - highlight_radius)
            highlight_circle_size = drawing.Size(2 * highlight_radius, 2 * highlight_radius)

            # Draw Highlight around current circle at index
            graphics.DrawEllipse(highlight_pen, drawing.Rectangle(highlight_position, highlight_circle_size))

            graphics.Dispose()

        else:
            Rhino.RhinoApp.WriteLine("Form or Highlight Bitmap is None...")

    def DrawDialog(self):
        # Draw the radial array of circles
        self.DrawCirclesOnBitmap(self.dialog_bitmap)
        
        # Draw the highlighted circle on top
        self.DrawCircleHighlight(self.pointed_circle_index)
        
        # Composite the bitmaps together if needed
        
        # Update the image view with the combined bitmap
        combined_bitmap = CombineBitmaps(self.dialog_bitmap, self.highlight_bitmap)
        self.image_view.Image = combined_bitmap

    def CombineBitmaps(background_bitmap, overlay_bitmap):
        # Combine the two bitmaps into one, preserving the background
        combined_bitmap = background_bitmap.Clone()
        graphics = drawing.Graphics(combined_bitmap)
        graphics.DrawImage(overlay_bitmap, drawing.Point(0, 0))
        graphics.Dispose()
        return combined_bitmap

    def OnMouseMove(self, e):
        if self.original_position:
            current_position = Rhino.UI.MouseCursor.Location
            
            vector_x = current_position.X - self.original_position.X
            vector_y = current_position.Y - self.original_position.Y

            # Calculate the angle of the vector relative to the positive x-axis
            angle = math.atan2(vector_y, vector_x)
            
            # Convert the angle to degrees
            angle_degrees = math.degrees(angle)
            
            # Ensure the angle is positive and between 0 and 360 degrees
            if angle_degrees < 0:
                angle_degrees += 360
            
            # Round the angle to the nearest whole number for printing
            rounded_angle_degrees = round(angle_degrees)
            
            # Calculate the circle index based on the angle
            num_circles = 8
            circle_index = int(angle_degrees / (360 / num_circles))

            # Pass the circle centers list to the DrawCircleHighlight method
            self.DrawCircleHighlight(circle_index, self.circle_centers)

    def OnMouseUp(self, e):
        if e.Button == System.Windows.Forms.MouseButtons.Middle:
            # Rhino.RhinoApp.WriteLine("Middle Mouse Up")
            self.original_position = None
            if self.form:
                self.form.Close()
                self.form = None
        # else:
        #     Rhino.RhinoApp.WriteLine("Other mouse up")

def DoSomething():
    if scriptcontext.sticky.has_key('MyMouseCallback'):
        callback = scriptcontext.sticky['MyMouseCallback']
        if callback:
            callback.Enabled = False
            callback = None
            scriptcontext.sticky.Remove('MyMouseCallback')
    else:
        callback = MyMouseCallback()
        callback.Enabled = True
        scriptcontext.sticky['MyMouseCallback'] = callback
        Rhino.RhinoApp.WriteLine("Click somewhere...")

if __name__ == "__main__":
    DoSomething()

Hello,

Here’s a somewhat crude prototype implementation (without logos or button images yet mind you… that allows you to run Rhino commands from a list of 8 commands.

The command is ran from a predetermined list of commands, the user presses middle mouse down and “flicks” in the direction of that button and when they release middle mouse it will run the command that they “flicked” to.

The flicked to button gets a highlight for visual clarity. The other random colors are just placeholder to represent “different icons” for now.

Presumably the user could specify any commands they want in this list. populating the icons associated with said commands may prove more challenging though perhaps there is a get property for commands/macros that lets me return the image associated with it?

Right now the commands are hardcoded in a list at line 173.

Anyways, here’s the code below.

Still working through stuff but figured I’d share where I’m at

Code:

import Eto.Forms as forms
import Eto.Drawing as drawing
import System
import Rhino.UI
import math

class CircleProperties:
    def __init__(self):
        self.fill_color = drawing.Colors.White
        self.border_color = None  # Will be set dynamically
        self.highlight_color = drawing.Colors.Orange


class TransparentDialog(forms.Dialog[bool]):
    def __init__(self):
        self.Size = drawing.Size(300, 300)
        self.Padding = drawing.Padding(5)
        self.Opacity = 1  

        # Initialize circle properties list
        self.circle_properties = [CircleProperties() for _ in range(8)]

        self.dialog_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)
        self.DrawCirclesOnBitmap(self.dialog_bitmap, None)

        # Add an extra bitmap for the highlighted circle
        self.highlight_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)
        
        self.image_view = forms.ImageView()
        self.image_view.Image = self.dialog_bitmap

        layout = forms.DynamicLayout()
        layout.AddRow(self.image_view)
        self.Content = layout

        self.Title = "PIE Menu 0.1"

        self.selected_button_index = None  # Initialize selected button index variable

    def DrawCirclesOnBitmap(self, bitmap, pointed_circle_index):
        circle_radius = 100
        num_circles = 8
        button_radius = 35

        graphics = drawing.Graphics(bitmap)

        center_x = bitmap.Size.Width / 2
        center_y = bitmap.Size.Height / 2

        for i, properties in enumerate(self.circle_properties):
            angle = (2 * math.pi / num_circles) * i
            circle_center_x = center_x + circle_radius * math.cos(angle)
            circle_center_y = center_y + circle_radius * math.sin(angle)
            circle_position = drawing.Point(circle_center_x - button_radius, circle_center_y - button_radius)
            circle_size = drawing.Size(2 * button_radius, 2 * button_radius)
          
            circ_brush = drawing.SolidBrush(properties.fill_color)  # Use circle properties

            highlight_color = drawing.Colors.Orange  # Set the highlight color
            border_color = drawing.Color.FromArgb(255, i * 30, (i * 30) % 255, 255)  # Assign a unique border color for each circle
            
            if i == pointed_circle_index:
                border_pen = drawing.Pen(highlight_color, 10)
                self.selected_button_index = i  # Update selected button index
            else:
                border_pen = drawing.Pen(border_color, 5)

            graphics.FillEllipse(circ_brush, drawing.Rectangle(circle_position, circle_size))
            graphics.DrawEllipse(border_pen, drawing.Rectangle(circle_position, circle_size))    

        graphics.Dispose()


class SampleTransparentEtoForm(forms.Form):
    def __init__(self):
        self.WindowStyle = forms.WindowStyle.None
        
        self.Styles.Add[forms.Panel]("transparent", self.MyFormStyler)
        self.Style = "transparent"
        
        self.AutoSize = False
        self.Resizable = False
        self.TopMost = True
        self.ShowActivated = False
        self.Size = drawing.Size(300, 300)
        self.Padding = drawing.Padding(20)
        self.MovableByWindowBackground = False

        self.close_button = forms.Button(Text="Close")
        self.close_button.Click += self.OnCloseButtonClick

        self.dialog = TransparentDialog()

        layout = forms.DynamicLayout()
        layout.AddRow(self.dialog.Content)

        self.Content = layout

    def MyFormStyler(self, control):
        self.BackgroundColor = drawing.Colors.Transparent
        window = control.ControlObject
        if hasattr(window, "AllowsTransparency"):
            window.AllowsTransparency = True
        if hasattr(window, "Background"):
            brush = window.Background.Clone()
            brush.Opacity = 0
            window.Background = brush
        else:
            color = window.BackgroundColor
            window.BackgroundColor = color.FromRgba(0, 0, 0, 0)

    def OnCloseButtonClick(self, sender, e):
        # This method can be used later for other purposes
        self.Close()

class MyMouseCallback(Rhino.UI.MouseCallback):
    def __init__(self):
        self.original_position = None
        self.form = None
        self.mouse_moved = False

    def OnMouseDown(self, e):    
        if e.Button == System.Windows.Forms.MouseButtons.Middle:
            self.original_position = Rhino.UI.MouseCursor.Location
            self.form = SampleTransparentEtoForm()
            form_loc = drawing.Point(self.original_position.X, self.original_position.Y)
            self.form.Location = form_loc
            self.form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
            self.form.Show()
        else:
            Rhino.RhinoApp.WriteLine("Failure...")
            return

    def OnMouseMove(self, e):
        if self.original_position:
            current_position = Rhino.UI.MouseCursor.Location
            
            if current_position != self.original_position:
                self.mouse_moved = True

                vector_x = current_position.X - self.original_position.X
                vector_y = current_position.Y - self.original_position.Y

                angle = math.atan2(vector_y, vector_x)
                angle_degrees = math.degrees(angle)
                
                if angle_degrees < 0:
                    angle_degrees += 360
                
                num_circles = 8
                pointed_circle_index = int(angle_degrees / (360 / num_circles))
                # Rhino.RhinoApp.WriteLine("Button Index: "+ str(pointed_circle_index) )

                # Create a new bitmap for highlighting
                highlight_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)
                # Call the DrawCirclesOnBitmap method with the highlighted circle
                self.form.dialog.DrawCirclesOnBitmap(highlight_bitmap, pointed_circle_index)
                # Update the ImageView with the new highlight_bitmap
                self.form.dialog.image_view.Image = highlight_bitmap

    def OnMouseUp(self, e):
        if e.Button == System.Windows.Forms.MouseButtons.Middle:
            if self.mouse_moved:
                self.original_position = None
                if self.form:
                    selected_index = self.form.dialog.selected_button_index
                    # Close the form first
                    self.form.Close()
                    self.form = None
                    if selected_index is not None:
                        Rhino.RhinoApp.WriteLine("Button {} was selected.".format(selected_index))
                        # Execute the corresponding Rhino command
                        commands = ["Point", "Polyline", "Box", "Sphere", "Cone", "Torus", "Polygon", "BooleanUnion"]
                        if selected_index < len(commands):
                            Rhino.RhinoApp.RunScript(commands[selected_index], False)
                        else:
                            Rhino.RhinoApp.WriteLine("No command found for index {}.".format(selected_index))
            elif self.form:
                # If mouse hasn't moved, close the form
                self.form.Close()
                self.form = None


def DoSomething():
    if scriptcontext.sticky.has_key('MyMouseCallback'):
        callback = scriptcontext.sticky['MyMouseCallback']
        if callback:
            callback.Enabled = False
            callback = None
            scriptcontext.sticky.Remove('MyMouseCallback')
    else:
        callback = MyMouseCallback()
        callback.Enabled = True
        scriptcontext.sticky['MyMouseCallback'] = callback
        Rhino.RhinoApp.WriteLine("Click somewhere...")

if __name__ == "__main__":
    DoSomething()


EDIT:

Obviously I’m having an issue with the dialog spawn location… It’s offset from the mouse location and this offset varies in size depending on the screen position the cursor is in when it creates the dialog… any ideas would be VERY much appreciated :wink: @clement ?:pray:

4 Likes

Hi @michaelvollrath, i’d suggest two changes both in this function:

    def OnMouseDown(self, e):    
        if e.Button == System.Windows.Forms.MouseButtons.Middle:
            self.original_position = Rhino.UI.MouseCursor.Location
            
            self.form = SampleTransparentEtoForm()
            
            # subtract half form size from mouse down location
            form_loc = drawing.Point(self.original_position.X-150, self.original_position.Y-150)
            
            self.form.Location = form_loc
            self.form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
            self.form.Show()
            
            # prevent that the MMB Popup comes up
            e.Cancel = True
        else:
            Rhino.RhinoApp.WriteLine("Failure...")
            return

the first is to offset the form location by half the form size. This seems to work over here and the form comes up where expected. The second is by setting e.Cancel=True to suppress the MMB Popup toolbar.

Did you miss that, i wrote it 4 day ago above ?

Btw. sometimes i see that the form comes up and i release the mouse too early and the event to invoke a command does not capture my mouse up. Probably because it happens on the form itself. Wouldn’t it be better to let Eto handle all the mouse events instead of using the MouseCallback ?

Since your script conflicts with the default MMB Popup, it could be ran using it as MMB Command by default. Then you could get rid of the Callback and try to handle the events which happen on the form. It’s just an idea which i also used in my first mockup. But i have not tested if it would then work to “flick” the mouse as the events can only be captured on the form.

_
c.

1 Like

btw. there is a thing i don’t understand yet. You have a form and a dialog ? Why is that ?

_
c.

1 Like

Thanks @clement, no I did not miss that but forgot to respond. I actually had that code initially and removed it because it doesn’t work on my computer.

Depending on where I am on the screen, the offset gets larger or smaller as well.

Here’s a video showing the variable offset, the closest it ever gets is when I’m in the upper right of the screen, the further I move from that point the greater the offset becomes.

I checked and have changed my monitors scaling as well to no effect. I’m stumped on it as I’ve never had issues before with positioning Eto dialogs

Yes, this occurs if the mouse is over one of the UI elements when released. I haven’t figured out how to fix that yet.

Can Eto listen to the mouse in real-time so I can still figure out the vector math of where it is “pointing”

Technically no button is ever pressed, there is no hover/click functionality with the Eto elements as the selection method is only the “flick”. I’m open of course to changing this but it seems to be the fastest selection method with the least amount of steps from what I have tested so far.

It does make sense running it as the MMB command for now. Longer term I see the user being able to set a custom key bind for this. maybe they want to use TAB or some other hotkey to spawn the radial menu. MMB to me makes sense for now but I know this is occupied for other users. Some users of course have different mouse buttons such as side buttons which could work well I suppose.

This is entirely because of my lack of understanding of Eto in general. I’m trying to find documentation that explains all the bits and bobs but having a hard time of it.

Thanks for your response!

Hi @michaelvollrath, what happens if you pass the initial positon using this instead:

pos = Eto.Forms.Mouse.Position
form.Location = Eto.Drawing.Point(pos.X-150, pos.Y-150)

I’m using this in my mockup and just assigned the script to a special button on my mouse. It seems to work reliable with 1 or 2 screens connected.

Yes, it does this usually relative to the control you set it up for. In case of a form or an image view there are a lot of events you can capture: link

Ok, note that TAB is occupied as well.

You should only use one Eto.Forms.Form if you want this modeless and have the transparent background. To make things a bit easier i would suggest you set two things in MyFormStyler for now:

brush.Opacity=0.01 
window.BackgroundColor = color.FromRgba(0, 0, 0, 1)

while this makes your dialog almost fully transparent, Eto will still be able to detect mouse events in the transparent areas. If you make it 100% transparent, this is not the case, the events get masked by the transparency.

Two more suggestions: If you detect that a user clicked with anything else as MMB, eg. with LMB, do not display this:

Rhino.RhinoApp.WriteLine("Failure...")

just hide (or close) your form instead.

Last: You might get rid of Eto.Forms.Dialog and draw everything in a single bitmap or drawable. It is fast enough and should keep things easier for now.

_
c.

1 Like

Thanks @clement, I’ll dig into these suggestions and report back. I appreciate the feedback!

1 Like

Hi @clement,

From your suggestion and more research, I was able to update my previous code to use only a form and not a dialog now.

It’s much easier to manage now, thanks for that.

I managed to get some graphics working, dynamic highlight, etc. and associate the “buttons” with Rhino commands.

Also now that I’m not doing the double dialog/form situation, the UI shows up on the Mouse Cursor position as expected so that’s great!

I’m now trying to implement the mouse event handling from the Form itself because in it’s current implement, despite being transparent, the “flick highlighting” only starts working when I first draw off of the form. If I am on the form after spawn with the mouse, it doesn’t work oddly. So I’m trying to sort that right now but otherwise it’s generally working well.

EDIT:
Silly me… I forgot I changed the form transparency to “visible” despite not being able to visually see it. So the mouse was tracking against the form despite me not wanting it to. I made it completely invisible again and that fixed it. (I updated the code below to reflect this)

Here’s a video of progress, for now I’m associating custom images and commands for testing but as mentioned earlier, I would like to make this user customizable at some point.

Code Thus Far:

import Eto.Forms as forms
import Eto.Drawing as drawing
import System
import Rhino.UI
import math

class CircleProperties:
    def __init__(self, icon_path=""):
        self.fill_color = drawing.Colors.White
        self.border_color = None  # Will be set dynamically
        self.highlight_color = drawing.Colors.Orange
        self.icon_path = icon_path  # Icon path attribute with a default value of ""

class TransparentForm(forms.Form):
    def __init__(self):
        self.Title = "PIE Menu 0.1"
        self.WindowStyle = forms.WindowStyle.None
        self.AutoSize = False
        self.Resizable = False
        self.TopMost = True
        self.ShowActivated = False
        self.Size = drawing.Size(300, 300)
        self.Padding = drawing.Padding(20)
        self.MovableByWindowBackground = False

        # Initialize circle properties list with icon paths
        icon_paths = [
        "user_set_path",
        "user_set_path",
        "user_set_path",
        "user_set_path",
        "user_set_path",
        "user_set_path",
        "user_set_path",
        "user_set_path",
        "user_set_path",
        "user_set_path",
        ]
        self.circle_properties = [CircleProperties(icon_path) for icon_path in icon_paths]

        self.form_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)
        self.DrawCirclesOnBitmap(self.form_bitmap, None)

        self.image_view = forms.ImageView()
        self.image_view.Image = self.form_bitmap

        layout = forms.DynamicLayout()
        layout.AddRow(self.image_view)
        self.Content = layout

        self.selected_button_index = None  # Initialize selected button index variable

        # Add transparent style
        self.Styles.Add[forms.Panel]("transparent", self.MyFormStyler)
        self.Style = "transparent"
        
    def DrawCirclesOnBitmap(self, bitmap, pointed_circle_index):
        # Clear the bitmap
        graphics = drawing.Graphics(bitmap)
        graphics.Clear(drawing.Colors.Transparent)

        circle_radius = 100
        num_circles = 8
        button_radius = 35

        center_x = bitmap.Size.Width / 2
        center_y = bitmap.Size.Height / 2

        for i, properties in enumerate(self.circle_properties):
            angle = (2 * math.pi / num_circles) * i
            circle_center_x = center_x + circle_radius * math.cos(angle)
            circle_center_y = center_y + circle_radius * math.sin(angle)
            circle_position = drawing.Point(circle_center_x - button_radius, circle_center_y - button_radius)
            circle_size = drawing.Size(2 * button_radius, 2 * button_radius)
          
            circ_brush = drawing.SolidBrush(properties.fill_color)  # Use circle properties

            highlight_color = drawing.Colors.Orange  # Set the highlight color
            border_color = drawing.Colors.LightGrey  # Assign a unique border color for each circle
            
            if i == pointed_circle_index:
                border_pen = drawing.Pen(highlight_color, 10)
                self.selected_button_index = i  # Update selected button index
            else:
                border_pen = drawing.Pen(border_color, 5)

            graphics.FillEllipse(circ_brush, drawing.Rectangle(circle_position, circle_size))
            graphics.DrawEllipse(border_pen, drawing.Rectangle(circle_position, circle_size)) 
            # Draw the image
            icon_bitmap = drawing.Bitmap(properties.icon_path)
            graphics.DrawImage(icon_bitmap, circle_center_x - 25, circle_center_y - 25, 50, 50)

        graphics.Dispose()

    def MyFormStyler(self, control):
        self.BackgroundColor = drawing.Colors.Transparent
        window = control.ControlObject
        if hasattr(window, "AllowsTransparency"):
            window.AllowsTransparency = True
        if hasattr(window, "Background"):
            brush = window.Background.Clone()
            brush.Opacity = 0  # Adjust opacity as needed
            window.Background = brush
        else:
            color = window.BackgroundColor
            window.BackgroundColor = color.FromRgba(0, 0, 0, 0)

    def Cleanup(self):
        # Close the form if it's shown
        if self.Visible:
            self.Close()
        
        # Dispose of the form's resources
        self.Dispose()

class MyMouseCallback(Rhino.UI.MouseCallback):
    def __init__(self):
        self.original_position = None
        self.form = None
        self.mouse_moved = False

    def OnMouseDown(self, e):
        if e.Button == System.Windows.Forms.MouseButtons.Middle:
            Rhino.RhinoApp.WriteLine("MMB Down")
            if self.form:
                self.form.Cleanup()  # Clean up any existing UI
            self.original_position = Rhino.UI.MouseCursor.Location
            self.form = TransparentForm()
            form_loc = drawing.Point(self.original_position.X - 150, self.original_position.Y - 150)
            Rhino.RhinoApp.WriteLine(str(form_loc))
            self.form.Location = form_loc
            self.form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
            self.form.Show()

    def OnMouseMove(self, e):
        if self.original_position:
            current_position = Rhino.UI.MouseCursor.Location
            
            if current_position != self.original_position:
                self.mouse_moved = True

                vector_x = current_position.X - self.original_position.X
                vector_y = current_position.Y - self.original_position.Y

                angle = math.atan2(vector_y, vector_x)
                angle_degrees = math.degrees(angle)
                
                if angle_degrees < 0:
                    angle_degrees += 360
                
                num_circles = 8
                pointed_circle_index = int(angle_degrees / (360 / num_circles))
                # Rhino.RhinoApp.WriteLine("Button Index: "+ str(pointed_circle_index) )

                # Create a new bitmap for highlighting
                highlight_bitmap = drawing.Bitmap(drawing.Size(300, 300), drawing.PixelFormat.Format32bppRgba)
                # Call the DrawCirclesOnBitmap method with the highlighted circle
                self.form.DrawCirclesOnBitmap(highlight_bitmap, pointed_circle_index)
                # Update the ImageView with the new highlight_bitmap
                self.form.image_view.Image = highlight_bitmap

    def OnMouseUp(self, e):
        if e.Button == System.Windows.Forms.MouseButtons.Middle:
            if self.mouse_moved:
                self.original_position = None
                if self.form:
                    selected_index = self.form.selected_button_index
                    # Close the form first
                    self.form.Close()
                    self.form = None
                    if selected_index is not None:
                        # Execute the corresponding Rhino command
                        commands = ["Floor", "Wall", "Door", "Window", "Electrical", "Plumbing", "Object", "Room"]
                        Rhino.RhinoApp.WriteLine("Running {} tool...".format(commands[selected_index]))
                        if selected_index < len(commands):
                            Rhino.RhinoApp.RunScript(commands[selected_index], False)
                        else:
                            Rhino.RhinoApp.WriteLine("No command found for index {}.".format(selected_index))
            elif self.form:
                # If mouse hasn't moved, close the form
                self.form.Close()
                self.form = None


def DoSomething():
    if scriptcontext.sticky.has_key('MyMouseCallback'):
        callback = scriptcontext.sticky['MyMouseCallback']
        if callback:
            callback.Enabled = False
            callback = None
            scriptcontext.sticky.Remove('MyMouseCallback')
    else:
        callback = MyMouseCallback()
        callback.Enabled = True
        scriptcontext.sticky['MyMouseCallback'] = callback
        Rhino.RhinoApp.WriteLine("Click somewhere...")

if __name__ == "__main__":
    DoSomething()

Thanks for all your help, I’m really learning a lot in this exercise!

13 Likes

Hi @michaelvollrath, me too !

I guess you’ll might setup a way to connect the images with command scripts and names, eg. in multiple indexed class instances of type “Macro”, then store these in an external file which you can read and write. The easiest way i could imagine is to just copy buttons from Rhino’s workspace in the pie chart using drag & drop

…just dreaming :wink:

_
c.

1 Like

Very nice job !!!
I tested also some radial menu, and actually one improvement to be flexible will be to be able to “populate” the ring from a new or existing RUI file, and then retrieving the icons/buttons put inside the RUI… this way creating a new button and icon could be done directly inside Rhino, and switching between different RUI file depending of the use of anybody, I did some test without success, and extracting and reinporting all icons inside py is not flexible and modifying the data tree inside python is time consuming…and RUI file already had link to button, function and svg file…and are also linked to the “icon size” of Rhino software…
Radial will just a container/reader of Rui… but is it even possible… or a dream? :slight_smile:

1 Like

Perhaps something along those lines is possible given that macros contain execution logic, a thumbnail if specified, and the the command name of course. Those are the 3 things needed for what I shared to work. Actually only a thumbnail and command name is needed.

Since we can “right click → new toolbar button” on a toolbar. perhaps we can do this with the radio menu where we can harvest the code happening at that function to populate Rhino toolbars, spawn the UI that allows you to select a macro to add to a toolbar and when you choose one it harvest the macro command name and icon only and updates the radial GUI script with that new info.

A hacky way would be to find the folder path in the Rhino install where the macro icons live and string search for said icons based on a single command value then just use that file path for the radial icon.

Probably over simplifying that process.

Drag&Drop would be great of course.

I think you’re on to the right direction with that. If we create a macro in Rhino it should have all the pieces we need. The question is… how to read that data into our script properly. :thinking:

@michaelvollrath I did some test, creating a new container, called RADIAL, and Toolbar 01, 02…each toolbar could be easyly populate with function directly from rhino… and save verything as RADIAL.rui.
Using a simple program I can read the Rui, which is an XML, and filtering, I can extract some dictionnaries… but if I retrive the GUID for each button… Is there a way to generate a button from this GUID easyly?.. Thanks for your help…

That is my blocking point…

Here is my XML reader code:


# coding utf-8
import xml.etree.ElementTree as ET

# Chemin vers votre fichier .rui
chemin_fichier_rui = r'C:\Users\Antoine\Desktop\ICONS\RADIAL.rui'

with open(chemin_fichier_rui, 'r', encoding='utf-8') as fichier:
    contenu_rui = fichier.read()

# Essayez de parser la chaîne XML
try:
    arbre = ET.fromstring(contenu_rui)
    print("Le fichier XML a été parsé avec succès.")
except ET.ParseError as e:
    print(f"Erreur de parsing XML: {e}")
# Dictionnaire pour stocker les informations
informations = {
    'tool_bars': [],
    'icons': {}
}

# Extraire les informations des barres d'outils
for tool_bar in arbre.findall('.//tool_bar'):
    nom_toolbar = tool_bar.find('.//text/locale_1036').text if tool_bar.find('.//text/locale_1036') is not None else 'Nom inconnu'
    items_toolbar = []
    for item in tool_bar.findall('.//tool_bar_item'):
        nom_item = item.find('.//text/locale_1033').text if item.find('.//text/locale_1033') is not None else 'Nom inconnu'
        items_toolbar.append(nom_item)
    informations['tool_bars'].append({'nom': nom_toolbar, 'items': items_toolbar})

# Extraire les informations des icônes
for icon in arbre.findall('.//icon'):
    guid = icon.get('guid')
    nom_fichier = icon.get('name')
    informations['icons'][guid] = nom_fichier

# Affichage des informations extraites
from pprint import pprint
pprint(informations)
2 Likes

@antoine Nice! I haven’t had a chance to dig into this yet but I’m looking forward to checking it out!

For the script I shared above we don’t even need a button, just the toolbar icon and the action it runs.

A colleague shared this with me recently and I thought it was somewhat relevant to this discussion.

Graphically and UX wise it’s a bit “rough” in my opinion but the concept is interesting. Essentially a UI where you can customize your toolbar(s) but I thought it was interesting how they have the list of full tools/macros and then you can drag and drop onto the “graph space” below to create a grid or pseudo radial menu.

It could be interesting to let users define multiple PIE menus that they could bind to specific keys/buttons.

Perhaps you have one for drafting, one for boolean operations, dimensioning, etc. you could have each of those linked to a different mouse button (like on a mouse that has more than just LMB, MMB, and RMB) and then have very easy and quick access to multiple tool sets directly on top of where you want to be modeling/drawing instead of having to run multiple aliases or hunt and pecking toolbar icons somewhere.

UX Food for thought…

EDIT:

Another PIE menu you can customize (a sketchup plugin)

5 Likes

This would be great to have in Rhino!

2 Likes