#!/usr/bin/env python3 import sys import os import re import json import xml.etree.ElementTree # Version check f"Python 3.6+ is required" class UserException(Exception): pass def find(f, array): for a in array: if f(a): return f def input_default(prompt, default=""): str = input(f"{prompt} [{default}]: ") if str == "": return default return str def is_valid_slug(slug): return re.match(r'^[a-zA-Z0-9_\-]+$', slug) != None def str_to_identifier(s): if not s: return "_" # Identifiers can't start with a number if s[0].isdigit(): s = "_" + s # Capitalize first letter s = s[0].upper() + s[1:] # Replace special characters with underscore s = re.sub(r'\W', '_', s) return s def create_plugin(slug, plugin_dir=None): # Check slug if not is_valid_slug(slug): raise UserException("Slug must only contain ASCII letters, numbers, '-', and '_'.") if not plugin_dir: plugin_dir = os.path.join(slug, '') # Check if plugin directory exists if os.path.exists(plugin_dir): raise UserException(f"Directory {plugin_dir} already exists") # Create plugin directory os.mkdir(plugin_dir) # Create manifest try: create_manifest(slug, plugin_dir) except Exception as e: os.rmdir(plugin_dir) raise e # Create subdirectories os.mkdir(os.path.join(plugin_dir, "src")) os.mkdir(os.path.join(plugin_dir, "res")) # Create Makefile makefile = """# If RACK_DIR is not defined when calling the Makefile, default to two directories above RACK_DIR ?= ../.. # FLAGS will be passed to both the C and C++ compiler FLAGS += CFLAGS += CXXFLAGS += # Careful about linking to shared libraries, since you can't assume much about the user's environment and library search path. # Static libraries are fine, but they should be added to this plugin's build system. LDFLAGS += # Add .cpp files to the build SOURCES += $(wildcard src/*.cpp) # Add files to the ZIP package when running `make dist` # The compiled plugin and "plugin.json" are automatically added. DISTRIBUTABLES += res DISTRIBUTABLES += $(wildcard LICENSE*) # Include the Rack plugin Makefile framework include $(RACK_DIR)/plugin.mk """ with open(os.path.join(plugin_dir, "Makefile"), "w") as f: f.write(makefile) # Create plugin.hpp plugin_hpp = """#pragma once #include using namespace rack; // Declare the Plugin, defined in plugin.cpp extern Plugin* pluginInstance; // Declare each Model, defined in each module source file // extern Model* modelMyModule; """ with open(os.path.join(plugin_dir, "src/plugin.hpp"), "w") as f: f.write(plugin_hpp) # Create plugin.cpp plugin_cpp = """#include "plugin.hpp" Plugin* pluginInstance; void init(Plugin* p) { pluginInstance = p; // Add modules here // p->addModel(modelMyModule); // Any other plugin initialization may go here. // As an alternative, consider lazy-loading assets and lookup tables when your module is created to reduce startup times of Rack. } """ with open(os.path.join(plugin_dir, "src/plugin.cpp"), "w") as f: f.write(plugin_cpp) git_ignore = """/build /dist /plugin.so /plugin.dylib /plugin.dll .DS_Store """ with open(os.path.join(plugin_dir, ".gitignore"), "w") as f: f.write(git_ignore) print(f"Created template plugin in {plugin_dir}") os.system(f"cd {plugin_dir} && git init") print(f"You may use `make`, `make clean`, `make dist`, `make install`, etc in the {plugin_dir} directory.") def create_manifest(slug, plugin_dir="."): # Default manifest manifest = { 'slug': slug, } # Try to load existing manifest file manifest_filename = os.path.join(plugin_dir, 'plugin.json') try: with open(manifest_filename, "r") as f: manifest = json.load(f) except: pass # Query manifest information manifest['name'] = input_default("Plugin name", manifest.get('name', slug)) manifest['version'] = input_default("Version", manifest.get('version', "1.0.0")) manifest['license'] = input_default("License (if open-source, use license identifier from https://spdx.org/licenses/)", manifest.get('license', "proprietary")) manifest['brand'] = input_default("Brand (prefix for all module names)", manifest.get('brand', manifest['name'])) manifest['author'] = input_default("Author", manifest.get('author', "")) manifest['authorEmail'] = input_default("Author email (optional)", manifest.get('authorEmail', "")) manifest['authorUrl'] = input_default("Author website URL (optional)", manifest.get('authorUrl', "")) manifest['pluginUrl'] = input_default("Plugin website URL (optional)", manifest.get('pluginUrl', "")) manifest['manualUrl'] = input_default("Manual website URL (optional)", manifest.get('manualUrl', "")) manifest['sourceUrl'] = input_default("Source code URL (optional)", manifest.get('sourceUrl', "")) manifest['donateUrl'] = input_default("Donate URL (optional)", manifest.get('donateUrl', "")) manifest['changelogUrl'] = manifest.get('changelogUrl', "") if 'modules' not in manifest: manifest['modules'] = [] # Dump JSON with open(manifest_filename, "w") as f: json.dump(manifest, f, indent=" ") print(f"Manifest written to {manifest_filename}") def create_module(slug, panel_filename=None, source_filename=None): # Check slug if not is_valid_slug(slug): raise UserException("Slug must only contain ASCII letters, numbers, '-', and '_'.") # Read manifest manifest_filename = 'plugin.json' with open(manifest_filename, "r") as f: manifest = json.load(f) # Check if module manifest exists module_manifest = find(lambda m: m['slug'] == slug, manifest['modules']) if module_manifest: print(f"Module {slug} already exists in plugin.json. Edit this file to modify the module manifest.") else: # Add module to manifest module_manifest = {} module_manifest['slug'] = slug module_manifest['name'] = input_default("Module name", slug) module_manifest['description'] = input_default("One-line description (optional)") tags = input_default("Tags (comma-separated, case-insensitive, see https://github.com/VCVRack/Rack/blob/v1/src/tag.cpp for list)") tags = tags.split(",") tags = [tag.strip() for tag in tags] if len(tags) == 1 and tags[0] == "": tags = [] module_manifest['tags'] = tags manifest['modules'].append(module_manifest) # Write manifest with open(manifest_filename, "w") as f: json.dump(manifest, f, indent=" ") print(f"Added {slug} to {manifest_filename}") # Check filenames if panel_filename and source_filename: if not os.path.exists(panel_filename): raise UserException(f"Panel not found at {panel_filename}.") print(f"Panel found at {panel_filename}. Generating source file.") if os.path.exists(source_filename): if input_default(f"{source_filename} already exists. Overwrite?", "n").lower() != "y": return # Read SVG XML tree = xml.etree.ElementTree.parse(panel_filename) components = panel_to_components(tree) print(f"Components extracted from {panel_filename}") # Write source source = components_to_source(components, slug) with open(source_filename, "w") as f: f.write(source) print(f"Source file generated at {source_filename}") # Append model to plugin.hpp identifier = str_to_identifier(slug) # Tell user to add model to plugin.hpp and plugin.cpp print(f""" To enable the module, add extern Model* model{identifier}; to plugin.hpp, and add p->addModel(model{identifier}); to the init() function in plugin.cpp.""") def panel_to_components(tree): ns = { "svg": "http://www.w3.org/2000/svg", "inkscape": "http://www.inkscape.org/namespaces/inkscape", } # Get components layer root = tree.getroot() groups = root.findall(".//svg:g[@inkscape:label='components']", ns) # Illustrator uses `id` for the group name. if len(groups) < 1: groups = root.findall(".//svg:g[@id='components']", ns) if len(groups) < 1: raise UserException("Could not find \"components\" layer on panel") # Get circles and rects components_group = groups[0] circles = components_group.findall(".//svg:circle", ns) rects = components_group.findall(".//svg:rect", ns) components = {} components['params'] = [] components['inputs'] = [] components['outputs'] = [] components['lights'] = [] components['widgets'] = [] for el in circles + rects: c = {} # Get name name = el.get('{http://www.inkscape.org/namespaces/inkscape}label') if name is None: name = el.get('id') name = str_to_identifier(name).upper() c['name'] = name # Get color style = el.get('style') color_match = re.search(r'fill:\S*#(.{6});', style) color = color_match.group(1).lower() c['color'] = color # Get position if el.tag == "{http://www.w3.org/2000/svg}rect": x = float(el.get('x')) y = float(el.get('y')) width = float(el.get('width')) height = float(el.get('height')) c['x'] = round(x, 3) c['y'] = round(y, 3) c['width'] = round(width, 3) c['height'] = round(height, 3) c['cx'] = round(x + width / 2, 3) c['cy'] = round(y + height / 2, 3) elif el.tag == "{http://www.w3.org/2000/svg}circle": cx = float(el.get('cx')) cy = float(el.get('cy')) c['cx'] = round(cx, 3) c['cy'] = round(cy, 3) if color == 'ff0000': components['params'].append(c) if color == '00ff00': components['inputs'].append(c) if color == '0000ff': components['outputs'].append(c) if color == 'ff00ff': components['lights'].append(c) if color == 'ffff00': components['widgets'].append(c) # Sort components top_left_sort = lambda w: w['cy'] + 0.01 * w['cx'] components['params'] = sorted(components['params'], key=top_left_sort) components['inputs'] = sorted(components['inputs'], key=top_left_sort) components['outputs'] = sorted(components['outputs'], key=top_left_sort) components['lights'] = sorted(components['lights'], key=top_left_sort) components['widgets'] = sorted(components['widgets'], key=top_left_sort) print(f"Found {len(components['params'])} params, {len(components['inputs'])} inputs, {len(components['outputs'])} outputs, {len(components['lights'])} lights, and {len(components['widgets'])} custom widgets.") return components def components_to_source(components, slug): identifier = str_to_identifier(slug) source = "" source += f"""#include "plugin.hpp" struct {identifier} : Module {{""" # Params source += """ enum ParamIds {""" for c in components['params']: source += f""" {c['name']}_PARAM,""" source += """ PARAMS_LEN };""" # Inputs source += """ enum InputIds {""" for c in components['inputs']: source += f""" {c['name']}_INPUT,""" source += """ INPUTS_LEN };""" # Outputs source += """ enum OutputIds {""" for c in components['outputs']: source += f""" {c['name']}_OUTPUT,""" source += """ OUTPUTS_LEN };""" # Lights source += """ enum LightIds {""" for c in components['lights']: source += f""" {c['name']}_LIGHT,""" source += """ LIGHTS_LEN };""" source += f""" {identifier}() {{ config(PARAMS_LEN, INPUTS_LEN, OUTPUTS_LEN, LIGHTS_LEN);""" for c in components['params']: source += f""" configParam({c['name']}_PARAM, 0.f, 1.f, 0.f, "");""" for c in components['inputs']: source += f""" configInput({c['name']}_INPUT, "");""" for c in components['outputs']: source += f""" configOutput({c['name']}_OUTPUT, "");""" source += """ } void process(const ProcessArgs& args) override { } };""" source += f""" struct {identifier}Widget : ModuleWidget {{ {identifier}Widget({identifier}* module) {{ setModule(module); setPanel(createPanel(asset::plugin(pluginInstance, "res/{slug}.svg"))); addChild(createWidget(Vec(RACK_GRID_WIDTH, 0))); addChild(createWidget(Vec(box.size.x - 2 * RACK_GRID_WIDTH, 0))); addChild(createWidget(Vec(RACK_GRID_WIDTH, RACK_GRID_HEIGHT - RACK_GRID_WIDTH))); addChild(createWidget(Vec(box.size.x - 2 * RACK_GRID_WIDTH, RACK_GRID_HEIGHT - RACK_GRID_WIDTH)));""" # Params if len(components['params']) > 0: source += "\n" for c in components['params']: if 'x' in c: source += f""" addParam(createParam(mm2px(Vec({c['x']}, {c['y']})), module, {identifier}::{c['name']}_PARAM));""" else: source += f""" addParam(createParamCentered(mm2px(Vec({c['cx']}, {c['cy']})), module, {identifier}::{c['name']}_PARAM));""" # Inputs if len(components['inputs']) > 0: source += "\n" for c in components['inputs']: if 'x' in c: source += f""" addInput(createInput(mm2px(Vec({c['x']}, {c['y']})), module, {identifier}::{c['name']}_INPUT));""" else: source += f""" addInput(createInputCentered(mm2px(Vec({c['cx']}, {c['cy']})), module, {identifier}::{c['name']}_INPUT));""" # Outputs if len(components['outputs']) > 0: source += "\n" for c in components['outputs']: if 'x' in c: source += f""" addOutput(createOutput(mm2px(Vec({c['x']}, {c['y']})), module, {identifier}::{c['name']}_OUTPUT));""" else: source += f""" addOutput(createOutputCentered(mm2px(Vec({c['cx']}, {c['cy']})), module, {identifier}::{c['name']}_OUTPUT));""" # Lights if len(components['lights']) > 0: source += "\n" for c in components['lights']: if 'x' in c: source += f""" addChild(createLight>(mm2px(Vec({c['x']}, {c['y']})), module, {identifier}::{c['name']}_LIGHT));""" else: source += f""" addChild(createLightCentered>(mm2px(Vec({c['cx']}, {c['cy']})), module, {identifier}::{c['name']}_LIGHT));""" # Widgets if len(components['widgets']) > 0: source += "\n" for c in components['widgets']: if 'x' in c: source += f""" // mm2px(Vec({c['width']}, {c['height']})) addChild(createWidget(mm2px(Vec({c['x']}, {c['y']}))));""" else: source += f""" addChild(createWidgetCentered(mm2px(Vec({c['cx']}, {c['cy']}))));""" source += f""" }} }}; Model* model{identifier} = createModel<{identifier}, {identifier}Widget>("{slug}");""" return source def usage(script): text = f"""VCV Rack Plugin Development Helper Usage: {script} ... Commands: createplugin [plugin dir] A directory will be created and initialized with a minimal plugin template. If no plugin directory is given, the slug is used. createmanifest [plugin dir] Creates a `plugin.json` manifest file in an existing plugin directory. If no plugin directory is given, the current directory is used. createmodule [panel file] [source file] Adds a new module to the plugin manifest in the current directory. If a panel and source file are given, generates a template source file initialized with components from a panel file. Example: {script} createmodule MyModule res/MyModule.svg src/MyModule.cpp See https://vcvrack.com/manual/PanelTutorial.html for creating SVG panel files. """ print(text) def parse_args(args): script = args.pop(0) if len(args) == 0: usage(script) return cmd = args.pop(0) if cmd == 'createplugin': create_plugin(*args) elif cmd == 'createmodule': create_module(*args) elif cmd == 'createmanifest': create_manifest(*args) else: print(f"Command not found: {cmd}") if __name__ == "__main__": try: parse_args(sys.argv) except KeyboardInterrupt: pass except UserException as e: print(e) sys.exit(1)