├── .ocp_vscode ├── requirements.txt ├── .gitignore ├── demos ├── requirements.txt ├── box.py ├── cycloidal_gear.py ├── build123d_tea_cup.py └── parametric_enclosure.py ├── .github ├── dependabot-disabled.yml └── workflows │ ├── dependabot-auto-merge.yml │ └── ci.yml ├── LICENSE ├── README.md ├── __ocp_action_api.py └── action.yml /.ocp_vscode: -------------------------------------------------------------------------------- 1 | {"port":3939} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cadquery==2.4.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | /model.* 3 | /.idea -------------------------------------------------------------------------------- /demos/requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/gumyr/build123d -------------------------------------------------------------------------------- /demos/box.py: -------------------------------------------------------------------------------- 1 | import cadquery as cq 2 | 3 | show_object( 4 | cq.Workplane("XY").box(1, 2, 3), 5 | "simple_box", {"color": (0.25, 1.0, 0.75), "alpha": 0.5}, 6 | ) 7 | -------------------------------------------------------------------------------- /.github/dependabot-disabled.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "saturday" 8 | time: "09:00" 9 | - package-ecosystem: "github-actions" 10 | directory: "/.github/workflows/" 11 | schedule: 12 | interval: "weekly" 13 | day: "saturday" 14 | time: "09:00" -------------------------------------------------------------------------------- /demos/cycloidal_gear.py: -------------------------------------------------------------------------------- 1 | # https://cadquery.readthedocs.io/en/latest/examples.html#cycloidal-gear 2 | 3 | import cadquery as cq 4 | from math import sin, cos, pi, floor 5 | 6 | 7 | # define the generating function 8 | def hypocycloid(t, r1, r2): 9 | return ( 10 | (r1 - r2) * cos(t) + r2 * cos(r1 / r2 * t - t), 11 | (r1 - r2) * sin(t) + r2 * sin(-(r1 / r2 * t - t)), 12 | ) 13 | 14 | 15 | def epicycloid(t, r1, r2): 16 | return ( 17 | (r1 + r2) * cos(t) - r2 * cos(r1 / r2 * t + t), 18 | (r1 + r2) * sin(t) - r2 * sin(r1 / r2 * t + t), 19 | ) 20 | 21 | 22 | def gear(t, r1=4, r2=1): 23 | if (-1) ** (1 + floor(t / 2 / pi * (r1 / r2))) < 0: 24 | return epicycloid(t, r1, r2) 25 | else: 26 | return hypocycloid(t, r1, r2) 27 | 28 | 29 | # create the gear profile and extrude it 30 | result = ( 31 | cq.Workplane("XY") 32 | .parametricCurve(lambda t: gear(t * 2 * pi, 6, 1)) 33 | .twistExtrude(15, 90) 34 | .faces(">Z") 35 | .workplane() 36 | .circle(2) 37 | .cutThruAll() 38 | ) 39 | 40 | show_object(result) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yeicor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: "Dependabot Pull Request Approve and Merge" 2 | 3 | on: "pull_request_target" 4 | 5 | permissions: 6 | pull-requests: "write" 7 | contents: "write" 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: "ubuntu-latest" 12 | # Checking the actor will prevent your Action run failing on non-Dependabot 13 | # PRs but also ensures that it only does work for Dependabot PRs. 14 | if: "${{ github.actor == 'dependabot[bot]' }}" 15 | steps: 16 | # This first step will fail if there's no metadata and so the approval 17 | # will not occur. 18 | - name: "Dependabot metadata" 19 | id: "dependabot-metadata" 20 | uses: "dependabot/fetch-metadata@v2" 21 | with: 22 | github-token: "${{ secrets.GITHUB_TOKEN }}" 23 | # Here the PR gets approved. 24 | - uses: "actions/checkout@v4" 25 | - name: "Approve a PR" 26 | run: "gh pr review --approve $PR_URL" 27 | env: 28 | PR_URL: "${{ github.event.pull_request.html_url }}" 29 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 30 | # Finally, this sets the PR to allow auto-merging for patch and minor 31 | # updates if all checks pass 32 | - name: "Enable auto-merge for Dependabot PRs" 33 | #if: "${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}" 34 | run: "gh pr merge --auto --squash $PR_URL" 35 | env: 36 | PR_URL: "${{ github.event.pull_request.html_url }}" 37 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "main" 5 | workflow_dispatch: { } 6 | 7 | jobs: 8 | build-models: 9 | name: "Build demo models" 10 | runs-on: "ubuntu-latest" 11 | 12 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 13 | permissions: 14 | contents: "read" # to read the repository contents 15 | pull-requests: "read" # to read pull requests 16 | pages: "write" # to deploy to Pages 17 | id-token: "write" # to verify the deployment originates from an appropriate source 18 | 19 | # Deploy to the github-pages environment 20 | environment: 21 | name: "github-pages" 22 | # url: "${{ steps.deployment.outputs.page_url }}" 23 | 24 | # WARNING: You also need to go to Settings > Pages and set the source to "GitHub Actions" 25 | 26 | steps: 27 | 28 | # Checkout your repository 29 | - uses: "actions/checkout@v4" 30 | 31 | # Optional: Install your preferred Python version and set up caches 32 | - uses: "actions/setup-python@v5" 33 | with: 34 | python-version: "3.11" 35 | cache: "pip" 36 | 37 | # Optional: Install your preferred Python packages 38 | - run: "pip install -r demos/requirements.txt" 39 | 40 | # Run the action 41 | - uses: "./" # Yeicor/ocp-action@v 42 | with: 43 | scripts: "demos/box.py|demos/cycloidal_gear.py|demos/parametric_enclosure.py|demos/build123d_tea_cup.py" 44 | # formats: "STL|STEP|AMF|SVG|TJS|DXF|VRML|VTP|3MF|GLTF" 45 | # tolerance: "0.1" 46 | # angular-tolerance: "0.1" 47 | y-up: "true" # Defaults to false 48 | # website: "." 49 | # website-screenshot: "true" -------------------------------------------------------------------------------- /demos/build123d_tea_cup.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | sys.path.append('..') 4 | from __ocp_action_api import show_object 5 | from build123d import * 6 | 7 | wall_thickness = 3 * MM 8 | fillet_radius = wall_thickness * 0.49 9 | 10 | with BuildPart() as tea_cup: 11 | # Create the bowl of the cup as a revolved cross-section 12 | with BuildSketch(Plane.XZ) as bowl_section: 13 | with BuildLine(): 14 | # Start & end points with control tangents 15 | s = Spline( 16 | (30 * MM, 10 * MM), 17 | (69 * MM, 105 * MM), 18 | tangents=((1, 0.5), (0.7, 1)), 19 | tangent_scalars=(1.75, 1), 20 | ) 21 | # Lines to finish creating ½ the bowl shape 22 | Polyline(s @ 0, s @ 0 + Vector(10 * MM, -10 * MM), (0, 0), (0, (s @ 1).Y), s @ 1) 23 | make_face() # Create a filled 2D shape 24 | revolve(axis=Axis.Z) 25 | # Hollow out the bowl with openings on the top and bottom 26 | offset(amount=-wall_thickness, openings=tea_cup.faces().filter_by(GeomType.PLANE)) 27 | # Add a bottom to the bowl 28 | with Locations((0, 0, (s @ 0).Y)): 29 | Cylinder(radius=(s @ 0).X, height=wall_thickness) 30 | # Smooth out all the edges 31 | fillet(tea_cup.edges(), radius=fillet_radius) 32 | 33 | # Determine where the handle contacts the bowl 34 | handle_intersections = [ 35 | tea_cup.part.find_intersection( 36 | Axis(origin=(0, 0, vertical_offset), direction=(1, 0, 0)) 37 | )[-1][0] 38 | for vertical_offset in [35 * MM, 80 * MM] 39 | ] 40 | # Create a path for handle creation 41 | with BuildLine(Plane.XZ) as handle_path: 42 | path_spline = Spline( 43 | handle_intersections[0] - (wall_thickness / 2, 0), 44 | handle_intersections[0] + (35 * MM, 30 * MM), 45 | handle_intersections[0] + (40 * MM, 60 * MM), 46 | handle_intersections[1] - (wall_thickness / 2, 0), 47 | tangents=((1, 1.25), (-0.2, -1)), 48 | ) 49 | # Align the cross-section to the beginning of the path 50 | with BuildSketch( 51 | Plane(origin=path_spline @ 0, z_dir=path_spline % 0) 52 | ) as handle_cross_section: 53 | RectangleRounded(wall_thickness, 8 * MM, fillet_radius) 54 | sweep() # Sweep handle cross-section along path 55 | 56 | if 'show_object' in globals(): 57 | show_object(tea_cup, "tea_cup") 58 | else: 59 | assert tea_cup.part.export_stl("tea_cup.stl"), "Failed to export STL" 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OCP action 2 | 3 | > [!WARNING] 4 | > This project has been superseded by [Yet Another CAD Viewer](https://github.com/yeicor-3d/yet-another-cad-viewer), and will no longer be actively maintained. 5 | 6 | GitHub Action that builds [OCP](https://github.com/CadQuery/OCP) models ([CadQuery](https://github.com/CadQuery/cadquery)/[Build123d](https://github.com/gumyr/build123d)/...), renders them and sets up a model viewer on Github Pages. 7 | 8 | ## Features 9 | 10 | - Automatically test your model(s) in your CI/CD pipeline 11 | - Automatically build the latest version of your model(s) for release. 12 | - No boilerplate: use the same code from the [CQ-editor](https://github.com/CadQuery/CQ-editor) and in your CI/CD 13 | pipeline. 14 | - Build a static website to showcase your latest model(s) automatically. 15 | - Take a screenshot of your model(s) and use it as a preview image. 16 | 17 | ## Usage 18 | 19 | This repository also serves as a demo. 20 | 21 | The only requirements are a python script to build the model and a [workflow](.github/workflows/ci.yml) to run the 22 | action. 23 | 24 | You can use links similar to the following sections to embed your model in your README.md and point to the interactive 25 | model viewer. The latest models can be downloaded from 26 | the [build artifacts](https://github.com/Yeicor/ocp-action/actions/workflows/ci.yml). 27 | 28 | ### Demo: [box.py](demos/box.py) 29 | 30 | [![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/box/simple_box.png)](https://yeicor-3d.github.io/ocp-action/?model=models/demos/box/simple_box.gltf) 31 | 32 | ![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/box/simple_box.svg) 33 | 34 | ### Demo: [cycloidal_gear.py](demos/cycloidal_gear.py) 35 | 36 | [![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/cycloidal_gear/cycloidal_gear.png)](https://yeicor-3d.github.io/ocp-action/?model=models/demos/cycloidal_gear/cycloidal_gear.gltf) 37 | 38 | ![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/cycloidal_gear/cycloidal_gear.svg) 39 | 40 | ### Demo: [parametric_enclosure.py](demos/parametric_enclosure.py) 41 | 42 | [![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/parametric_enclosure/topOfLid.png)](https://yeicor-3d.github.io/ocp-action/?model=models/demos/parametric_enclosure/topOfLid.gltf) 43 | 44 | [![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/parametric_enclosure/debug-bottom.png)](https://yeicor-3d.github.io/ocp-action/?model=models/demos/parametric_enclosure/debug-bottom.gltf) 45 | 46 | ![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/parametric_enclosure/topOfLid.svg) 47 | 48 | ![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/parametric_enclosure/debug-bottom.svg) 49 | 50 | ### Demo: [build123d_tea_cup.py](demos/build123d_tea_cup.py) 51 | 52 | [![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/build123d_tea_cup/tea_cup.png)](https://yeicor-3d.github.io/ocp-action/?model=models/demos/build123d_tea_cup/tea_cup.gltf) 53 | 54 | ![Demo](https://yeicor-3d.github.io/ocp-action/models/demos/build123d_tea_cup/tea_cup.svg) 55 | -------------------------------------------------------------------------------- /demos/parametric_enclosure.py: -------------------------------------------------------------------------------- 1 | # https://cadquery.readthedocs.io/en/latest/examples.html#id31 2 | 3 | import cadquery as cq 4 | 5 | # parameter definitions 6 | p_outerWidth = 100.0 # Outer width of box enclosure 7 | p_outerLength = 150.0 # Outer length of box enclosure 8 | p_outerHeight = 50.0 # Outer height of box enclosure 9 | 10 | p_thickness = 3.0 # Thickness of the box walls 11 | p_sideRadius = 10.0 # Radius for the curves around the sides of the box 12 | p_topAndBottomRadius = ( 13 | 2.0 # Radius for the curves on the top and bottom edges of the box 14 | ) 15 | 16 | p_screwpostInset = 12.0 # How far in from the edges the screw posts should be place. 17 | p_screwpostID = 4.0 # Inner Diameter of the screw post holes, should be roughly screw diameter not including threads 18 | p_screwpostOD = 10.0 # Outer Diameter of the screw posts.\nDetermines overall thickness of the posts 19 | 20 | p_boreDiameter = 8.0 # Diameter of the counterbore hole, if any 21 | p_boreDepth = 1.0 # Depth of the counterbore hole, if 22 | p_countersinkDiameter = 0.0 # Outer diameter of countersink. Should roughly match the outer diameter of the screw head 23 | p_countersinkAngle = 90.0 # Countersink angle (complete angle between opposite sides, not from center to one side) 24 | p_flipLid = True # Whether to place the lid with the top facing down or not. 25 | p_lipHeight = 1.0 # Height of lip on the underside of the lid.\nSits inside the box body for a snug fit. 26 | 27 | # outer shell 28 | oshell = ( 29 | cq.Workplane("XY") 30 | .rect(p_outerWidth, p_outerLength) 31 | .extrude(p_outerHeight + p_lipHeight) 32 | ) 33 | 34 | # weird geometry happens if we make the fillets in the wrong order 35 | if p_sideRadius > p_topAndBottomRadius: 36 | oshell = oshell.edges("|Z").fillet(p_sideRadius) 37 | oshell = oshell.edges("#Z").fillet(p_topAndBottomRadius) 38 | else: 39 | oshell = oshell.edges("#Z").fillet(p_topAndBottomRadius) 40 | oshell = oshell.edges("|Z").fillet(p_sideRadius) 41 | 42 | # inner shell 43 | ishell = ( 44 | oshell.faces("Z") 62 | .workplane(-p_thickness) 63 | .rect(POSTWIDTH, POSTLENGTH, forConstruction=True) 64 | .vertices() 65 | .circle(p_screwpostOD / 2.0) 66 | .circle(p_screwpostID / 2.0) 67 | .extrude(-1.0 * (p_outerHeight + p_lipHeight - p_thickness), True) 68 | ) 69 | 70 | # split lid into top and bottom parts 71 | (lid, bottom) = ( 72 | box.faces(">Z") 73 | .workplane(-p_thickness - p_lipHeight) 74 | .split(keepTop=True, keepBottom=True) 75 | .all() 76 | ) # splits into two solids 77 | 78 | # translate the lid, and subtract the bottom from it to produce the lid inset 79 | lowerLid = lid.translate((0, 0, -p_lipHeight)) 80 | cutlip = lowerLid.cut(bottom).translate( 81 | (p_outerWidth + p_thickness, 0, p_thickness - p_outerHeight + p_lipHeight) 82 | ) 83 | 84 | # compute centers for screw holes 85 | topOfLidCenters = ( 86 | cutlip.faces(">Z") 87 | .workplane(centerOption="CenterOfMass") 88 | .rect(POSTWIDTH, POSTLENGTH, forConstruction=True) 89 | .vertices() 90 | ) 91 | 92 | # add holes of the desired type 93 | if p_boreDiameter > 0 and p_boreDepth > 0: 94 | topOfLid = topOfLidCenters.cboreHole( 95 | p_screwpostID, p_boreDiameter, p_boreDepth, 2.0 * p_thickness 96 | ) 97 | elif p_countersinkDiameter > 0 and p_countersinkAngle > 0: 98 | topOfLid = topOfLidCenters.cskHole( 99 | p_screwpostID, p_countersinkDiameter, p_countersinkAngle, 2.0 * p_thickness 100 | ) 101 | else: 102 | topOfLid = topOfLidCenters.hole(p_screwpostID, 2.0 * p_thickness) 103 | 104 | # flip lid upside down if desired 105 | if p_flipLid: 106 | topOfLid = topOfLid.rotateAboutCenter((1, 0, 0), 180) 107 | 108 | # return the combined result 109 | #result = topOfLid.union(bottom) 110 | 111 | show_object(topOfLid, "topOfLid") 112 | debug(bottom, "bottom") 113 | -------------------------------------------------------------------------------- /__ocp_action_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from typing import Union, Dict, Optional 4 | 5 | import cadquery as cq 6 | from OCP.TopoDS import TopoDS_Shape 7 | 8 | # Retrieve the options 9 | out_dir = os.getenv("OCP_ACTION_OUT_DIR", ".") 10 | def_name = os.getenv("OCP_ACTION_DEF_NAME", "model") 11 | wanted_formats = os.getenv( 12 | "OCP_ACTION_WANTED_FORMATS", "STL|GLTF|SVG").split("|") 13 | tolerance = float(os.getenv("OCP_ACTION_TOLERANCE", "0.1")) 14 | angular_tolerance = float(os.getenv("OCP_ACTION_ANGULAR_TOLERANCE", "0.1")) 15 | y_up = os.getenv("OCP_ACTION_Y_UP", "true").lower() == "true" 16 | 17 | 18 | class WrappedShape: 19 | """It is common (cadquery, build123d) to wrap the OCP shapes in a class to add some metadata.""" 20 | wrapped: TopoDS_Shape 21 | 22 | 23 | class WrappedPartShape: 24 | """It is common (build123d) to wrap the OCP shapes in a class to add some metadata.""" 25 | part: WrappedShape 26 | 27 | 28 | def show_object(obj: Union[TopoDS_Shape, WrappedShape, WrappedPartShape, cq.Workplane], name: Optional[str] = None, 29 | options: Optional[Dict[str, any]] = None) -> None: 30 | """Emulates the cq-editor API to build the models instead.""" 31 | 32 | # Convert any OCP-like object to a cadquery object 33 | if hasattr(obj, "part"): # build123d 34 | obj = obj.part 35 | if hasattr(obj, "wrapped"): # cadquery / build123d 36 | obj = obj.wrapped 37 | if isinstance(obj, TopoDS_Shape): 38 | obj = cq.Shape(obj) 39 | 40 | name = name or def_name 41 | 42 | # Create the output directory 43 | os.makedirs(out_dir, exist_ok=True) 44 | 45 | # Convert Z-up to Y-up if needed 46 | if y_up: 47 | obj = obj.rotate((0,0,0),(1,0,0),-90) # CQ Shape rotate method 48 | 49 | # For each wanted format 50 | for fmt in wanted_formats: 51 | 52 | # Export the model in the wanted format 53 | model_path = f"{out_dir}/{name}.{fmt.lower()}" 54 | # Avoid collisions 55 | for i in range(1, 1000): 56 | if not os.path.exists(model_path): 57 | break 58 | model_path = f"{out_dir}/{name}-{i}.{fmt.lower()}" 59 | print(f"Exporting {model_path}...") 60 | if os.path.exists(model_path): 61 | print(f"WARNING: {model_path} already exists, overwriting it...") 62 | 63 | if fmt == "GLTF": # Only assemblies support GLTF 64 | # Add color if specified 65 | color = None 66 | if "color" in (options or {}): 67 | color = cq.Color( 68 | *options["color"], a=options["alpha"] if "alpha" in options else 1) 69 | 70 | # Create a fake assembly just to export it as gltf 71 | try: 72 | cq.Assembly(obj, color=color, name=model_path).save( 73 | model_path, tolerance=tolerance, angularTolerance=angular_tolerance) 74 | except Exception as e: 75 | print(f"::warning ::Couldn't export {model_path} due to {e}") 76 | 77 | else: # Default export 78 | try: 79 | # noinspection PyTypeChecker 80 | cq.exporters.export( 81 | obj, model_path, fmt, tolerance=tolerance, angularTolerance=angular_tolerance) 82 | 83 | # Fix SVG files forcing a white background 84 | if fmt == "SVG": 85 | with open(model_path, "r") as f: 86 | svg = f.read() 87 | with open(model_path, "w") as f: 88 | f.write(svg.replace( 89 | " None: 101 | """Emulates the cq-editor API to build the models instead.""" 102 | # Force the name to be debug- 103 | if "name" in kwargs: 104 | kwargs["name"] = "debug-" + kwargs["name"] 105 | elif len(args) > 1: 106 | args = list(args) 107 | args[1] = "debug-" + args[1] 108 | else: 109 | args = list(args) 110 | args.append(f"debug-{time.time()}") 111 | # Force the color to be red with transparency 112 | if "options" in kwargs: 113 | kwargs["options"]["color"] = (1, 0, 0) 114 | kwargs["options"]["alpha"] = 0.5 115 | else: 116 | kwargs["options"] = {"color": (1, 0, 0), "alpha": 0.5} 117 | # Call the show_object function 118 | return show_object(*args, **kwargs) 119 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "OCP Action" 2 | author: "Yeicor" 3 | description: "GitHub Action that builds OCP models (CadQuery/Build123d/...), renders them and sets up a model viewer on Github Pages." 4 | branding: 5 | icon: "package" 6 | color: "yellow" 7 | inputs: 8 | scripts: 9 | description: "The |-separated scripts containing the model(s) to build." 10 | required: true 11 | default: "main.py" 12 | formats: 13 | description: "The |-separated formats in which to build all model(s)." 14 | required: false 15 | default: "STL|STEP|AMF|SVG|TJS|DXF|VRML|VTP|3MF|GLTF" 16 | tolerance: 17 | description: "The tolerance to use when building the model(s)." 18 | required: false 19 | default: "0.1" 20 | angular_tolerance: 21 | description: "The angular tolerance to use when building the model(s)." 22 | required: false 23 | default: "0.1" 24 | y-up: 25 | description: "If true, all model(s) are converted from Z-up to Y-up." 26 | required: false 27 | default: "false" 28 | website: 29 | description: "Relative path where a static website to visualize the models using GitHub Pages will be built. Models and renders will be available at //... Disabled if empty." 30 | required: false 31 | default: "." 32 | website-screenshot: 33 | description: "Whether to render the model(s) as a screenshot of the website." 34 | required: false 35 | default: "true" 36 | outputs: 37 | models-folder: 38 | description: "The root folders containing all built models, including the website if built." 39 | value: "${{ steps.build.outputs.models-folder }}" 40 | runs: 41 | using: "composite" 42 | steps: 43 | 44 | - name: "Install CadQuery for exporting OCP models" 45 | shell: "bash" 46 | run: "pip install -r ${{ github.action_path }}/requirements.txt" 47 | 48 | - name: "Build model(s)" 49 | id: "build" 50 | shell: "bash" 51 | run: | 52 | set -ex 53 | 54 | # Create a workspace for the models 55 | export TMPDIR=$PWD 56 | base_folder=$(mktemp -d) 57 | chmod -R +rX "$base_folder" # Fix permissions 58 | all_model_folders="" 59 | 60 | # For each script... 61 | for script in $(echo "${{ inputs.scripts }}" | tr "|" "\n"); do 62 | 63 | # Copy the library to the script's folder 64 | cp "${{ github.action_path }}/__ocp_action_api.py" "$(dirname "$script")" 65 | 66 | # Inject our import to define the show() function 67 | sed -i '1s/^/from __ocp_action_api import *\n/' $script 68 | 69 | # Define environment variables for the script 70 | export OCP_ACTION_OUT_DIR="$base_folder/$(realpath --relative-to="$GITHUB_WORKSPACE" "$(dirname "$script")")/$(basename "$script" .py)" 71 | export OCP_ACTION_DEF_NAME="$(basename "$script" .py)" 72 | export OCP_ACTION_WANTED_FORMATS="${{ inputs.formats }}" 73 | export OCP_ACTION_TOLERANCE="${{ inputs.tolerance }}" 74 | export OCP_ACTION_ANGULAR_TOLERANCE="${{ inputs.angular_tolerance }}" 75 | export OCP_ACTION_Y_UP="${{ inputs.y-up }}" 76 | 77 | # Build the model(s) 78 | pushd "$(dirname "$script")" 79 | python "$(basename "$script")" 80 | popd 81 | 82 | # Save the folder 83 | all_model_folders="$all_model_folders|$OCP_ACTION_OUT_DIR" 84 | done 85 | 86 | # Remove the first | from the list 87 | all_model_folders=$(echo "$all_model_folders" | cut -c2-) 88 | 89 | # Set the output as the base folder 90 | echo "models-folder=$base_folder" >> "$GITHUB_OUTPUT" 91 | 92 | - name: "Upload artifacts" 93 | uses: "actions/upload-artifact@v3" 94 | with: 95 | name: "Built models" 96 | path: "${{ steps.build.outputs.models-folder }}" 97 | 98 | - name: "Build and render website" 99 | if: "${{ inputs.website != '' }}" 100 | id: "website" 101 | shell: "bash" 102 | run: | 103 | set -ex 104 | 105 | # Copy all models to the website folder, to provide persistent links to latest models 106 | export TMPDIR=$PWD 107 | website_root_folder="$(mktemp -d)" 108 | chmod -R +rX "$website_root_folder" # Fix permissions 109 | website_folder="$(realpath "$website_root_folder/${{ inputs.website }}")" 110 | mkdir -p "$website_folder/models" 111 | cp -r "${{ steps.build.outputs.models-folder }}/"* "$website_folder/models" 112 | 113 | # If taking screenshots, use them as posters instead of plans 114 | poster_extension="svg" 115 | if [ "${{ inputs.website-screenshot }}" = "true" ]; then 116 | poster_extension="png" 117 | fi 118 | 119 | # Get all available glTF models as relative paths (sorted by modification date), and generate the HTML for them 120 | gltf_models=$(cd "$website_folder" && find -name "*.gltf" -printf "%T@\t%p\n" | sort -n | cut -f 2-) 121 | models_html="" 122 | first_model_path="" 123 | first_model_poster_path="" 124 | for model_path in $gltf_models; do 125 | model_name=$(basename "$model_path" .gltf) 126 | model_poster_path="$(echo "$model_path" | sed "s,.gltf,.$poster_extension,g")" 127 | selected="" 128 | if [ "$models_html" = "" ]; then 129 | selected=" selected" 130 | first_model_path="$model_path" 131 | first_model_poster_path="$model_poster_path" 132 | fi 133 | models_html="$models_html" 134 | done 135 | 136 | # Build a website for all models 137 | cat >"$website_folder/index.html" < 139 | 140 | 141 | 142 | 3D Models 143 | 144 | 145 | 149 | 150 | 151 | 157 | 158 | 159 | 160 | 161 |
162 |
163 | 164 | 165 | ${models_html} 166 |
167 |
168 |
169 | 194 | 249 | 250 | 251 | EOF 252 | 253 | # Generate a screenshot of the static site for each of the models 254 | if [ "${{ inputs.website-screenshot }}" = "true" ]; then 255 | sudo apt-get install chromium-browser 256 | find $website_folder 257 | for model_path in $gltf_models; do 258 | model_png_path="$(echo "$model_path" | sed 's,.gltf,.png,g')" 259 | chromium-browser --headless --disable-gpu --disable-web-security --user-data-dir="$website_folder/__chrome" \ 260 | --headless=new --virtual-time-budget=2500 --screenshot="test.png" \ 261 | "file://$website_folder/index.html?model=$model_path&noSlider=true" 262 | mv "test.png" "$website_folder/$model_png_path" 263 | done 264 | rm -rf "$website_folder/__chrome" 265 | fi 266 | 267 | # Copy website content to the models folder 268 | cp -r "$website_folder/" "${{ steps.build.outputs.models-folder }}" 269 | 270 | # Export the website root folder, fixing permissions first 271 | echo "website-folder=$website_root_folder" >> "$GITHUB_OUTPUT" 272 | 273 | # Boilerplate to deploy the website to GitHub Pages: 274 | - name: "Upload website as an artifact" 275 | if: "${{ inputs.website != '' }}" 276 | uses: "actions/upload-pages-artifact@v2" 277 | with: 278 | path: "${{ steps.website.outputs.website-folder }}" 279 | - name: "Deploy website artifact to GitHub Pages" 280 | if: "${{ inputs.website != '' }}" 281 | uses: "actions/deploy-pages@v2" 282 | --------------------------------------------------------------------------------