Adding back more extensions

This commit is contained in:
Mario Voigt 2022-10-13 00:05:56 +02:00
parent 8f112e2867
commit 41a3592340
159 changed files with 16518 additions and 38 deletions

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Cleanup Styles</name>
<id>fablabchemnitz.de.cleanup_styles</id>
<param name="tab" type="notebook">
<page name="tab_settings" gui-text="Settings">
<param name="dedicated_style_attributes" gui-text="Handling of dedicated style attributes" gui-description="We delete dedicated attributes like 'fill' or 'stroke'. Please choose an option what should happen with those properties." type="optiongroup" appearance="combo">
<option value="prefer_composed">Catch dedicated, but prefer composed (master) style</option>
<option value="prefer_dedicated">Catch dedicated and prefer over composed (master) style</option>
<option value="ignore">Ignore dedicated</option>
</param>
<param name="stroke_width_override" type="bool" gui-text="Override stroke width">false</param>
<param name="stroke_width" type="float" precision="3" min="0.0000" max="5.000" gui-text="Stroke width">0.100</param>
<param name="stroke_width_units" gui-text="Units" type="optiongroup" appearance="combo">
<option value="px">px</option>
<option value="pt">pt</option>
<option value="in">in</option>
<option value="cm">cm</option>
<option value="mm">mm</option>
</param>
<param name="stroke_opacity_override" type="bool" gui-text="Override stroke opacity">false</param>
<param name="stroke_opacity" type="float" precision="1" min="0.0" max="100.0" gui-text="Stroke opacity (%)">100.0</param>
<param name="reset_opacity" type="bool" gui-text="Reset opacity value in style attribute" gui-description="Reset stroke style attribute 'opacity'. Do not mix up with 'fill-opacity' and 'stroke-opacity'">true</param>
<param name="reset_stroke_attributes" type="bool" gui-text="Reset stroke* values in style attribute" gui-description="Remove 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linejoin', 'stroke-linecap', 'stroke-miterlimit' from style attribute">true</param>
<param name="reset_fill_attributes" type="bool" gui-text="Reset fill* values style attribute" gui-description="Sets 'fill:none;fill-opacity:1;' to style attribute">true</param>
<param name="apply_hairlines" type="bool" gui-text="Add additional hairline definition to style" gui-description="Adds 'vector-effect:non-scaling-stroke;' and '-inkscape-stroke:hairline;' Hint: stroke-width is kept in background. All hairlines still have a valued width.">true</param>
<param name="apply_black_strokes" type="bool" gui-text="Apply black strokes where strokes missing" gui-description="Adds 'stroke:#000000;' to style attribute">true</param>
<param name="remove_group_styles" type="bool" gui-text="Remove styles from groups" gui-description="Remove style attributes from parent groups. So we have styles directly at the level of visivle nodes!">false</param>
<param name="harmonize_colors" type="bool" gui-text="Harmonize colors" gui-description="Round up colors to the next 'full color'. Example: make rgb(253,0,0) to rgb(255,0,0) to receive clear red color.">false</param>
<param name="allow_half_tones" type="bool" gui-text="Allow half-tone colors" gui-description="Allow rounding up to half-tone colors">false</param>
<separator/>
<label>This extension works on current selection or for complete document</label>
</page>
<page name="tab_about" gui-text="About">
<label appearance="header">Cleanup Styles</label>
<label>This extension is useful for adjusting the stroke width and opacity of large groups. Usually for good laser cutting the line width has to match a maximum width to be recognized as a hairline. Additionally often opacity issues cause that lines are missed by laser cutter.</label>
<label>2020 - 2022 / written by Mario Voigt (Stadtfabrikanten e.V. / FabLab Chemnitz)</label>
<spacer/>
<label appearance="header">Online Documentation</label>
<label appearance="url">https://y.stadtfabrikanten.org/cleanupstyles</label>
<spacer/>
<label appearance="header">Contributing</label>
<label appearance="url">https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X</label>
<label appearance="url">mailto:mario.voigt@stadtfabrikanten.org</label>
<spacer/>
<label appearance="header">MightyScape Extension Collection</label>
<label>This piece of software is part of the MightyScape for Inkscape Extension Collection and is licensed under GNU GPL v3</label>
<label appearance="url">https://y.stadtfabrikanten.org/mightyscape-overview</label>
</page>
<page name="tab_donate" gui-text="Donate">
<label appearance="header">Coffee + Pizza</label>
<label>We are the Stadtfabrikanten, running the FabLab Chemnitz since 2016. A FabLab is an open workshop that gives people access to machines and digital tools like 3D printers, laser cutters and CNC milling machines.</label>
<spacer/>
<label>You like our work and want to support us? You can donate to our non-profit organization by different ways:</label>
<label appearance="url">https://y.stadtfabrikanten.org/donate</label>
<spacer/>
<label>Thanks for using our extension and helping us!</label>
<image>../000_about_fablabchemnitz.svg</image>
</page>
</param>
<effect needs-live-preview="true">
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Colors/Gradients/Filters"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">cleanup_styles.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,258 @@
#!/usr/bin/env python3
'''
Copyright (C) 2013 Matthew Dockrey (gfish @ cyphertext.net)
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Based on
- coloreffect.py by Jos Hirth and Aaron C. Spike
- cleanup.py (https://github.com/attoparsec/inkscape-extensions) by attoparsec
Author: Mario Voigt / FabLab Chemnitz
Mail: mario.voigt@stadtfabrikanten.org
Last Patch: 12.04.2021
License: GNU GPL v3
Notes:
- This extension does not check if attributes contain duplicates properties like "opacity:1;fill:#393834;fill-opacity:1;opacity:1;fill:#393834;fill-opacity:1". We assume the SVG syntax is correct
'''
import inkex
import re
import numpy as np
class CleanupStyles(inkex.EffectExtension):
groups = []
roundUpColors = []
roundUpColors = [
[ 0, 0, 0], #black | eri1
[ 0, 0, 255], #blue | eri2
[ 0, 255, 0], #green | eri3
[255, 0, 0], #red | eri4
[255, 0, 255], #magenta | eri5
[ 0, 255, 255], #cyan | eri6
[255, 255, 0], #yellow | eri7
#half tones
[128, 0, 255], #violet | eri8
[0 , 128, 255], #light blue | eri9
[255, 128, 0], #orange | eri10
[255, 0, 128], #pink | eri11
[128, 255, 0], #light green | eri12
[0 , 255, 128], #mint | eri13
]
def add_arguments(self, pars):
pars.add_argument("--tab")
pars.add_argument("--mode", default="Lines", help="Join paths with lines or polygons")
pars.add_argument("--dedicated_style_attributes", default="ignore", help="Handling of dedicated style attributes")
pars.add_argument("--stroke_width_override", type=inkex.Boolean, default=False, help="Override stroke width")
pars.add_argument("--stroke_width", type=float, default=0.100, help="Stroke width")
pars.add_argument("--stroke_width_units", default="mm", help="Stroke width unit")
pars.add_argument("--stroke_opacity_override", type=inkex.Boolean, default=False, help="Override stroke opacity")
pars.add_argument("--stroke_opacity", type=float, default="100.0", help="Stroke opacity (%)")
pars.add_argument("--reset_opacity", type=inkex.Boolean, default=True, help="Reset stroke style attribute 'opacity'. Do not mix up with 'fill-opacity' and 'stroke-opacity'")
pars.add_argument("--reset_stroke_attributes", type=inkex.Boolean, default=True, help="Remove 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linejoin', 'stroke-linecap', 'stroke-miterlimit' from style attribute")
pars.add_argument("--reset_fill_attributes", type=inkex.Boolean, default=True, help="Sets 'fill:none;fill-opacity:1;' to style attribute")
pars.add_argument("--apply_hairlines", type=inkex.Boolean, default=True, help="Adds 'vector-effect:non-scaling-stroke;' and '-inkscape-stroke:hairline;' Hint: stroke-width is kept in background. All hairlines still have a valued width.")
pars.add_argument("--apply_black_strokes", type=inkex.Boolean, default=True, help="Adds 'stroke:#000000;' to style attribute")
pars.add_argument("--remove_group_styles", type=inkex.Boolean, default=False, help="Remove styles from groups")
pars.add_argument("--harmonize_colors", type=inkex.Boolean, default=False, help="Round up colors to the next 'full color'. Example: make rgb(253,0,0) to rgb(255,0,0) to receive clear red color.")
pars.add_argument("--allow_half_tones", type=inkex.Boolean, default=False, help="Allow rounding up to half-tone colors")
def closestColor(self, colors, color):
colors = np.array(colors)
color = np.array(color)
distances = np.sqrt(np.sum((colors-color)**2, axis=1))
index_of_smallest = np.where(distances==np.amin(distances))
smallest_distance = colors[index_of_smallest]
return smallest_distance
def effect(self):
self.roundUpColors = [
[ 0, 0, 0], #black | eri1
[ 0, 0, 255], #blue | eri2
[ 0, 255, 0], #green | eri3
[255, 0, 0], #red | eri4
[255, 0, 255], #magenta | eri5
[ 0, 255, 255], #cyan | eri6
[255, 255, 0], #yellow | eri7
[255, 255, 255], #white | eri8 - useful for engravings, not for line cuttings
]
if self.options.allow_half_tones is True:
self.roundUpColors.extend([
[128, 0, 255], #violet | eri9
[0 , 128, 255], #light blue | eri10
[255, 128, 0], #orange | eri11
[255, 0, 128], #pink | eri12
[128, 255, 0], #light green | eri13
[128, 255, 255], #lighter blue| eri14
[0 , 255, 128], #mint | eri15
[128, 128, 128], #grey | eri16
])
if len(self.svg.selected) == 0:
self.getAttribs(self.document.getroot())
else:
for element in self.svg.selected.values():
self.getAttribs(element)
#finally remove the styles from collected groups (if enabled)
if self.options.remove_group_styles is True:
for group in self.groups:
if group.attrib.has_key('style') is True:
group.attrib.pop('style')
def getAttribs(self, node):
self.changeStyle(node)
for child in node:
self.getAttribs(child)
#stroke and fill styles can be included in style attribute or they can exist separately (can occure in older SVG files). We do not parse other attributes than style
def changeStyle(self, node):
#we check/modify the style of all shapes (not groups)
if isinstance(node, inkex.ShapeElement) and not isinstance(node, inkex.Group):
# the final styles applied to this element (with influence from top level elements like groups)
specified_style = node.specified_style()
specifiedStyleAttributes = str(specified_style).split(';') #array
specifiedStyleAttributesDict = {}
if len(specified_style) > 0: #Style "specified_style" might contain just empty '' string which will lead to failing dict update
for specifiedStyleAttribute in specifiedStyleAttributes:
specifiedStyleAttributesDict.update({'{}'.format(specifiedStyleAttribute.split(':')[0]): specifiedStyleAttribute.split(':')[1]})
#three options to handle dedicated attributes (attributes not in the "style" attribute, but separate):
# - just delete all dedicated properties
# - merge dedicated properties, and prefer them over those from specified style
# - merge dedicated properties, but prefer properties from specified style
dedicatedStyleAttributesDict = {}
popDict = []
# there are opacity (of group/parent), fill-opacity and stroke-opacity
popDict.extend(['opacity', 'stroke', 'stroke-opacity', 'stroke-width', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'fill', 'fill-opacity'])
for popItem in popDict:
if node.attrib.has_key(str(popItem)):
dedicatedStyleAttributesDict.update({'{}'.format(popItem): node.get(popItem)})
node.attrib.pop(popItem)
#inkex.utils.debug("specifiedStyleAttributesDict = " + str(specifiedStyleAttributesDict))
#inkex.utils.debug("dedicatedStyleAttributesDict = " + str(dedicatedStyleAttributesDict))
if self.options.dedicated_style_attributes == 'prefer_dedicated':
specifiedStyleAttributesDict.update(dedicatedStyleAttributesDict)
node.set('style', specifiedStyleAttributesDict)
elif self.options.dedicated_style_attributes == 'prefer_specified':
dedicatedStyleAttributesDict.update(specifiedStyleAttributesDict)
node.set('style', dedicatedStyleAttributesDict)
elif self.options.dedicated_style_attributes == 'ignore':
pass
# now parse the style with possibly merged dedicated attributes modded style attribute (dedicatedStyleAttributes)
if node.attrib.has_key('style') is False:
node.set('style', 'stroke:#000000;') #we add basic stroke color black. We cannot set to empty value or just ";" because it will not update properly
style = node.get('style')
#add missing style attributes if required
if style.endswith(';') is False:
style += ';'
if re.search('(;|^)stroke:(.*?)(;|$)', style) is None: #if "stroke" is None, add one. We need to distinguish because there's also attribute "-inkscape-stroke" that's why we check starting with ^ or ;
style += 'stroke:none;'
if self.options.stroke_width_override is True and "stroke-width:" not in style:
style += 'stroke-width:{:1.4f};'.format(self.svg.unittouu(str(self.options.stroke_width) + self.options.stroke_width_units))
if self.options.stroke_opacity_override is True and "stroke-opacity:" not in style:
style += 'stroke-opacity:{:1.1f};'.format(self.options.stroke_opacity / 100)
if self.options.apply_hairlines is True:
if "vector-effect:non-scaling-stroke" not in style:
style += 'vector-effect:non-scaling-stroke;'
if "-inkscape-stroke:hairline" not in style:
style += '-inkscape-stroke:hairline;'
if re.search('fill:(.*?)(;|$)', style) is None: #if "fill" is None, add one.
style += 'fill:none;'
#then parse the content and check what we need to adjust
declarations = style.split(';')
for i, decl in enumerate(declarations):
parts = decl.split(':', 2)
if len(parts) == 2:
(prop, val) = parts
prop = prop.strip().lower()
if prop == 'stroke-width' and self.options.stroke_width_override is True:
new_val = self.svg.unittouu(str(self.options.stroke_width) + self.options.stroke_width_units)
declarations[i] = prop + ':{:1.4f}'.format(new_val)
if prop == 'stroke-opacity' and self.options.stroke_opacity_override is True:
new_val = self.options.stroke_opacity / 100
declarations[i] = prop + ':{:1.1f}'.format(new_val)
if self.options.reset_opacity is True:
if prop == 'opacity':
declarations[i] = ''
if self.options.reset_stroke_attributes is True:
if prop == 'stroke-dasharray':
declarations[i] = ''
if prop == 'stroke-dashoffset':
declarations[i] = ''
if prop == 'stroke-linejoin':
declarations[i] = ''
if prop == 'stroke-linecap':
declarations[i] = ''
if prop == 'stroke-miterlimit':
declarations[i] = ''
if self.options.apply_black_strokes is True:
if prop == 'stroke':
if val == 'none':
new_val = '#000000'
declarations[i] = prop + ':' + new_val
if self.options.harmonize_colors is True:
if prop == 'fill':
if re.search('fill:none(.*?)(;|$)', style) is None:
rgb = inkex.Color(val).to_rgb()
closest_color = self.closestColor(self.roundUpColors, [rgb[0], rgb[1], rgb[2]])
rgbNew = inkex.Color((
int(closest_color[0][0]),
int(closest_color[0][1]),
int(closest_color[0][2])
), space='rgb')
declarations[i] = prop + ':' + str(inkex.Color(rgbNew).to_named())
if prop == 'stroke':
if re.search('stroke:none(.*?)(;|$)', style) is None:
rgb = inkex.Color(val).to_rgb()
closest_color = self.closestColor(self.roundUpColors, [rgb[0], rgb[1], rgb[2]])
rgbNew = inkex.Color((
int(closest_color[0][0]),
int(closest_color[0][1]),
int(closest_color[0][2])
), space='rgb')
declarations[i] = prop + ':' + str(inkex.Color(rgbNew).to_named())
if self.options.reset_fill_attributes is True:
if prop == 'fill':
new_val = 'none'
declarations[i] = prop + ':' + new_val
if prop == 'fill-opacity':
new_val = '1'
declarations[i] = prop + ':' + new_val
if self.options.apply_hairlines is False:
if prop == '-inkscape-stroke':
if val == 'hairline':
del declarations[i]
if prop == 'vector-effect':
if val == 'non-scaling-stroke':
del declarations[i]
node.set('style', ';'.join(declarations))
# if element is group we add it to collection to remove it's style after parsing all selected items
elif isinstance(node, inkex.ShapeElement) and isinstance(node, inkex.Group):
self.groups.append(node)
if __name__ == '__main__':
CleanupStyles().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Cleanup Styles",
"id": "fablabchemnitz.de.cleanup_styles",
"path": "cleanup_styles",
"dependent_extensions": null,
"original_name": "Cleanup",
"original_id": "com.attoparsec.filter.cleanup",
"license": "GNU GPL v2",
"license_url": "https://github.com/attoparsec/inkscape-extensions/blob/master/cleanup.py",
"comment": "ported to Inkscape v1 by Mario Voigt",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/cleanup_styles",
"fork_url": "https://github.com/attoparsec/inkscape-extensions",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Cleanup+Styles",
"inkscape_gallery_url": "https://inkscape.org/de/~MarioVoigt/%E2%98%85cleanup-styles",
"main_authors": [
"github.com/attoparsec",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1 @@
*__pycache__*

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Color Harmony</name>
<id>fablabchemnitz.de.color_harmony</id>
<param name="tab" type="notebook">
<page name="render" gui-text="Create Color Harmony">
<label>Select an object that is filled with the color that you want to use as a base for your palette.</label>
<param name="harmony" type="optiongroup" appearance="combo" gui-text="Color Harmony:" gui-description="The asterisk means that the Angle modificator parameter can be used to change the outcome.">
<option value="just_opposite">Just opposite</option>
<option value="split_complementary">Split complementary *</option>
<option value="three">Three colors</option>
<option value="four">Four colors</option>
<option value="rectangle">Rectangle *</option>
<option value="five">Five colors *</option>
<option value="similar_3">Three similar colors *</option>
<option value="similar_5">Five similar colors *</option>
<option value="similar_and_opposite">Similar and opposite *</option>
<option value="from_raster">From selected raster image</option>
</param>
<param name="factor" type="int" min="1" max="100" gui-text="Angle modificator *" appearance="full" gui-description="Factor for determining the angle on the color circle for some of the harmonies (those that are marked with an asterisk)">50</param>
<param name="sort" type="optiongroup" appearance="combo" gui-text="Sort by:">
<option value="by_hue">Hue, 0-360°</option>
<option value="hue_contiguous">Hue, start from largest gap</option>
<option value="by_saturation">Saturation</option>
<option value="by_value">Value</option>
</param>
<label appearance="header">Add shades</label>
<hbox>
<vbox>
<param type="bool" name="cooler" gui-text="Cooler">false</param>
<param type="bool" name="warmer" gui-text="Warmer">false</param>
<param type="bool" name="saturation" gui-text="Saturation">false</param>
</vbox>
<vbox>
<param type="bool" name="value" gui-text="Value">false</param>
<param type="bool" name="chroma" gui-text="Chroma">false</param>
<param type="bool" name="luma" gui-text="Luma">false</param>
</vbox>
<vbox>
<param type="bool" name="hue" gui-text="Hue">false</param>
<param type="bool" name="hue_luma" gui-text="Hue / Luma">false</param>
<param type="bool" name="luma_plus_chroma" gui-text="Luma plus Chroma">false</param>
</vbox>
<vbox>
<param type="bool" name="luma_minus_chroma" gui-text="Luma minus Chroma">false</param>
</vbox>
</hbox>
<param name="step_width" type="float" min="0" max="1" gui-text="Shading step width:">0.1</param>
<label appearance="header">Size</label>
<param name="size" type="int" min="0" max="10000" gui-text="Size:">10</param>
<param name="unit" type="optiongroup" appearance="combo" gui-text="Units:">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="in">in</option>
<option value="px">px</option>
<option value="pt">pt</option>
<option value="pc">pc</option>
</param>
<param name="delete_existing" type="bool" gui-text="Remove old palettes">true</param>
</page>
<page name="save" gui-text="Save as Palette File">
<label>Save all selected palettes to a (single) palette file</label>
<param name="palette_format" type="optiongroup" appearance="combo" gui-text="Palette file format:">
<option value="gimp">Gimp Palette (.gpl)</option>
<option value="krita">Krita Palette (.kpl)</option>
<option value="scribus">Scribus Palette (.xml)</option>
</param>
<param type="path" name="palette_folder" gui-text="Folder to save palette file:" mode="folder"/>
<param name="palette_name" type="string" gui-text="Palette name">My Palette</param>
</page>
<page name="colorize" gui-text="Magic Colors">
<label>Press "Apply" to colorize the selection with the rendered palette.</label>
</page>
</param>
<effect needs-live-preview="true">
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Colors/Gradients/Filters"/>
</submenu>
</effects-menu>
<menu-tip>Generate color harmonies and save as palette file</menu-tip>
</effect>
<script>
<command location="inx" interpreter="python">color_harmony.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,318 @@
#!/usr/bin/env python3
# Color Harmony - Inkscape extension to generate
# palettes of colors that go well together
#
# Version 0.2 "golem"
#
# Copyright (C) 2009-2018 Ilya Portnov <portnov84@rambler.ru>
# (original 'palette-editor' tool, version 0.0.7)
# 2020 Maren Hachmann (extension-ification)
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
This extension allows you to automatically add guides to your Inkscape documents.
"""
#from math import sqrt
#import types
import inkex
from inkex import Group, Rectangle
from inkex.colors import is_color
from color_harmony.colorplus import ColorPlus
from color_harmony.harmonies import *
from color_harmony.shades import *
class ColorHarmony(inkex.EffectExtension):
"""Generate palettes of colors that go well together"""
color = ''
def add_arguments(self, pars):
# General options
pars.add_argument('--tab', default='render', help='Extension functionality to use') # options: render, save, colorize
# Render tab options
pars.add_argument('--harmony', default="five", help='Color harmony to generate, options: from_raster, just_opposite, split_complementary, three, four, rectangle, five, similar_3, similar_5, similar_and_opposite')
pars.add_argument('--sort', default="by_hue", help="Method to sort the palette by, options: by_hue, by_saturation, by_value, hue_contiguous")
pars.add_argument('--factor', default=50, type=int, help="Factor to affect the result, between 1 and 100. Default is 50. This modifies the angle between the resulting colors on the color wheel.")
pars.add_argument('--size', default=10, help="Size of the generated palette squares")
pars.add_argument('--unit', default='mm', help='Units') # options: mm, cm, in, px, pt, pc
pars.add_argument('--delete_existing', type=inkex.Boolean, help='Delete existing palettes before generating a new one')
# Shading: cooler, warmer, saturation, value, chroma, luma, hue, hue_luma, luma_plus_chroma, luma_minus_chroma
pars.add_argument( '--cooler', type=inkex.Boolean, help='Add shades with cooler color temperature')
pars.add_argument( '--warmer', type=inkex.Boolean, help='Add shades with warmer color temperature')
pars.add_argument( '--saturation', type=inkex.Boolean, help='Add shades with saturation steps')
pars.add_argument( '--value', type=inkex.Boolean, help='Add shades with value steps')
pars.add_argument( '--chroma', type=inkex.Boolean, help='Add shades with chroma steps')
pars.add_argument( '--luma', type=inkex.Boolean, help='Add shades with luma steps')
pars.add_argument( '--hue', type=inkex.Boolean, help='Add shades with hue steps')
pars.add_argument( '--hue_luma', type=inkex.Boolean, help='Add shades with hue and luma steps')
pars.add_argument( '--luma_plus_chroma', type=inkex.Boolean, help='Add shades with luma plus chroma steps')
pars.add_argument( '--luma_minus_chroma', type=inkex.Boolean, help='Add shades with luma minus chroma steps')
pars.add_argument('--step_width', type=float, default=0.1, help='Shader step width') # TODO: find out what range this can take on, and adjust min, max, default in inx
# Save tab options
pars.add_argument('--palette_format', default='gimp', help='Palette file format')
# options: gimp, krita, scribus
pars.add_argument('--palette_folder', help="Folder to save the palette file in")
pars.add_argument('--palette_name', help="Name of the palette")
# Colorize tab options
# no options currently
def effect(self):
if self.options.tab == "render":
if len(self.svg.selected) == 1:
for obj_id, obj in self.svg.selected.items():
fill = obj.style.get("fill")
if is_color(fill):
if self.options.delete_existing:
self.delete_existing_palettes()
self.color = ColorPlus(fill)
self.options.factor = self.options.factor/100
colors = self.create_harmony()
shades = self.create_shades(colors)
palettes = [colors] + shades
for i in range(len(palettes)):
self.render_palette(palettes[i], i)
else:
raise inkex.AbortExtension(
"Please select an object with a plain fill color.")
else:
raise inkex.AbortExtension(
"Please select one object.")
elif self.options.tab == "save":
palettes = self.get_palettes_in_doc()
if len(palettes) >= 1:
self.save_palette(palettes[0])
else:
raise inkex.AbortExtension(
"There is no rendered palette in the document. Please render a palette using the first tab of the dialog before you try to save it.")
elif self.options.tab == "colorize":
if len(self.svg.selected) > 0:
self.colorize()
else:
raise inkex.AbortExtension(
"Please select an object to colorize!")
# METHODS FOR EACH TAB
# --------------------
# Render tab
# ==========
def create_harmony(self):
harmony_functions = {
"from_raster": self.palette_from_raster, # not implemented yet
"just_opposite": self.opposite,
"split_complementary": self.splitcomplementary,
"three": self.nhues3,
"four": self.nhues4,
"rectangle": self.rectangle,
"five": self.fivecolors,
"similar_3": self.similar_3,
"similar_5": self.similar_5,
"similar_and_opposite": self.similaropposite,
}
# use appropriate function for the selected tab
colors = harmony_functions[self.options.harmony](self.color)
colors = self.sort_colors(colors)
return colors
def render_palette(self, colors, shift):
size = self.svg.unittouu(str(self.options.size)+self.options.unit)
top = 0 + shift * size
left = 0
layer = self.svg.get_current_layer() if self.svg.get_current_layer() is not None else self.document.getroot()
group_attribs = {inkex.addNS('label', 'inkscape'): "Palette ({harmony}, {color}) ".format(color=self.color, harmony=self.options.harmony)}
palette_group = Group(**group_attribs)
for color in colors:
palette_field = Rectangle(x=str(left),
y=str(top),
width=str(size),
height=str(size))
palette_field.style = {'fill': color}
palette_group.add(palette_field)
left += size
palette_group.transform.add_translate(0, self.svg.viewport_height + size)
layer.add(palette_group)
def palette_from_raster(self, color):
# TODO: implement
return []
def opposite(self, color):
colors = opposite(color)
return colors
def splitcomplementary(self, color):
colors = splitcomplementary(color, self.options.factor)
return colors
def nhues3(self, color):
colors = nHues(color, 3)
return colors
def nhues4(self, color):
colors = nHues(color, 4)
return colors
def rectangle(self, color):
colors = rectangle(color, self.options.factor)
return colors
def fivecolors(self, color):
colors = fiveColors(color, self.options.factor)
return colors
def similar_3(self, color):
colors = similar(color, 3, self.options.factor)
return colors
def similar_5(self, color):
colors = similar(color, 5, self.options.factor)
return colors
def similaropposite(self, color):
colors = similarAndOpposite(color, self.options.factor)
return colors
def create_shades(self, colors):
shades = []
shading_options = {
"cooler": cooler,
"warmer": warmer,
"saturation": saturation,
"value": value,
"chroma": chroma,
"luma": luma,
"hue": hue,
"hue_luma": hue_luma,
"luma_plus_chroma": luma_plus_chroma,
"luma_minus_chroma": luma_minus_chroma,
}
for option, function in shading_options.items():
if vars(self.options)[option] == True:
# shades are created per color,
# but we want to get one palette per shading step
shaded_colors = []
for i in range(len(colors)):
shaded_colors.append(function(colors[i], self.options.step_width))
pals = [list(a) for a in zip(*shaded_colors)]
shades += pals
return shades
def delete_existing_palettes(self):
"""Delete all palettes in the document"""
for palette in self.get_palettes_in_doc():
palette.delete()
# Save tab
# ========
def save_palette(self, palette):
# TODO: implement
# if not hasattr(self.palette, 'name'):
# if type(file_w) in [str, unicode]:
# self.palette.name = basename(file_w)
# else:
# self.palette.name='Colors'
pass
# Colorize tab
# ============
def colorize(self):
# TODO: implement
pass
# HELPER FUNCTIONS
# ----------------
def get_palettes_in_doc(self):
palettes = []
for group in self.svg.findall('.//svg:g'):
if group.get('inkscape:label').startswith('Palette ('):
palettes.append(group)
return palettes
def sort_colors(self, colors):
if self.options.sort == "by_hue":
colors.sort(key=lambda color: color.to_hsv()[0])
elif self.options.sort == "by_saturation":
colors.sort(key=lambda color: color.to_hsv()[1])
elif self.options.sort == "by_value":
colors.sort(key=lambda color: color.to_hsv()[2])
# this option looks nicer when the palette colors are similar red tones
# some of which have a hue close to 0
# and some of which have a hue close to 1
elif self.options.sort == "hue_contiguous":
# sort by hue first
colors.sort(key=lambda color: color.to_hsv()[0])
# now find out if the hues are maybe clustered around the 0 - 1 boundary
hues = [color.to_hsv()[0] for color in colors]
start_hue = 0
end_hue = 0
max_dist = 0
for i in range(len(colors)-1):
h1 = hues[i]
h2 = hues[i+1]
cur_dist = h2-h1
if cur_dist > max_dist and self.no_colors_in_between(h1, h2, hues):
max_dist = cur_dist
start_hue = h2
for i in range(len(colors)):
sorting_hue = hues[i] - start_hue
if sorting_hue > 1:
sorting_hue -=1
elif sorting_hue < 0:
sorting_hue += 1
hues[i] = sorting_hue
sorted_colors = [color for hue, color in sorted(zip(hues,colors))]
colors = sorted_colors
else:
raise inkex.AbortExtension(
"Please select one of the following sorting options: by_hue, by_saturation, by_value.")
return colors
def no_colors_in_between(self, hue1, hue2, hues):
for hue in hues:
if hue > hue1 and hue < hue2:
return False
return True
if __name__ == '__main__':
ColorHarmony().run()

View File

@ -0,0 +1 @@
# empty

View File

@ -0,0 +1,260 @@
#!/usr/bin/env python
# coding=utf-8
#
# Copyright (C) 2009-2018 Ilya Portnov <portnov84@rambler.ru>
# (original 'palette-editor' tool, version 0.0.7)
# 2020 Maren Hachmann (extension-ification)
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from math import cos, acos, sqrt, pi
import colorsys
from inkex.colors import Color
_HCY_RED_LUMA = 0.299
_HCY_GREEN_LUMA = 0.587
_HCY_BLUE_LUMA = 0.114
class ColorPlus(Color):
#HCYwts = 0.299, 0.587, 0.114
## HCY colour space.
#
# Copy&Paste from https://raw.githubusercontent.com/mypaint/mypaint/master/gui/colors/uicolor.py
# Copyright (C) 2012-2013 by Andrew Chadwick <andrewc-git@piffle.org>
#
# Frequently referred to as HSY, Hue/Chroma/Luma, HsY, HSI etc. It can be
# thought of as a cylindrical remapping of the YCbCr solid: the "C" term is the
# proportion of the maximum permissible chroma within the RGB gamut at a given
# hue and luma. Planes of constant Y are equiluminant.
#
# ref https://code.google.com/p/colour-space-viewer/
# ref git://anongit.kde.org/kdelibs in kdeui/colors/kcolorspaces.cpp
# ref http://blog.publicfields.net/2011/12/rgb-hue-saturation-luma.html
# ref Joblove G.H., Greenberg D., Color spaces for computer graphics.
# ref http://www.cs.rit.edu/~ncs/color/t_convert.html
# ref http://en.literateprograms.org/RGB_to_HSV_color_space_conversion_(C)
# ref http://lodev.org/cgtutor/color.html
# ref Levkowitz H., Herman G.T., "GLHS: a generalized lightness, hue, and
# saturation color model"
# For consistency, use the same weights that the Color and Luminosity layer
# blend modes use, as also used by brushlib's Colorize brush blend mode. We
# follow http://www.w3.org/TR/compositing/ here. BT.601 YCbCr has a nearly
# identical definition of luma.
def __init__(self, color=None, space='rgb'):
super().__init__(color)
def to_hcy(self):
"""RGB → HCY: R,G,B,H,C,Y ∈ [0, 1]
:param rgb: Color expressed as an additive RGB triple.
:type rgb: tuple (r, g, b) where 0r1, 0g1, 0b1.
:rtype: tuple (h, c, y) where 0h<1, but 0c2 and 0y1.
"""
r, g, b = self.to_floats()
# Luma is just a weighted sum of the three components.
y = _HCY_RED_LUMA*r + _HCY_GREEN_LUMA*g + _HCY_BLUE_LUMA*b
# Hue. First pick a sector based on the greatest RGB component, then add
# the scaled difference of the other two RGB components.
p = max(r, g, b)
n = min(r, g, b)
d = p - n # An absolute measure of chroma: only used for scaling.
if n == p:
h = 0.0
elif p == r:
h = (g - b)/d
if h < 0:
h += 6.0
elif p == g:
h = ((b - r)/d) + 2.0
else: # p==b
h = ((r - g)/d) + 4.0
h /= 6.0
# Chroma, relative to the RGB gamut envelope.
if r == g == b:
# Avoid a division by zero for the achromatic case.
c = 0.0
else:
# For the derivation, see the GLHS paper.
c = max((y-n)/y, (p-y)/(1-y))
return h, c, y
@staticmethod
def from_hcy(h, c, y):
"""HCY → RGB: R,G,B,H,C,Y ∈ [0, 1]
:param hcy: Color expressed as a Hue/relative-Chroma/Luma triple.
:type hcy: tuple (h, c, y) where 0h<1, but 0c2 and 0y1.
:rtype: ColorPlus object.
>>> n = 32
>>> diffs = [sum( [abs(c1-c2) for c1, c2 in
... zip( HCY_to_RGB(RGB_to_HCY([r/n, g/n, b/n])),
... [r/n, g/n, b/n] ) ] )
... for r in range(int(n+1))
... for g in range(int(n+1))
... for b in range(int(n+1))]
>>> sum(diffs) < n*1e-6
True
"""
if c == 0:
return y, y, y
h %= 1.0
h *= 6.0
if h < 1:
#implies (p==r and h==(g-b)/d and g>=b)
th = h
tm = _HCY_RED_LUMA + _HCY_GREEN_LUMA * th
elif h < 2:
#implies (p==g and h==((b-r)/d)+2.0 and b<r)
th = 2.0 - h
tm = _HCY_GREEN_LUMA + _HCY_RED_LUMA * th
elif h < 3:
#implies (p==g and h==((b-r)/d)+2.0 and b>=g)
th = h - 2.0
tm = _HCY_GREEN_LUMA + _HCY_BLUE_LUMA * th
elif h < 4:
#implies (p==b and h==((r-g)/d)+4.0 and r<g)
th = 4.0 - h
tm = _HCY_BLUE_LUMA + _HCY_GREEN_LUMA * th
elif h < 5:
#implies (p==b and h==((r-g)/d)+4.0 and r>=g)
th = h - 4.0
tm = _HCY_BLUE_LUMA + _HCY_RED_LUMA * th
else:
#implies (p==r and h==(g-b)/d and g<b)
th = 6.0 - h
tm = _HCY_RED_LUMA + _HCY_BLUE_LUMA * th
# Calculate the RGB components in sorted order
if tm >= y:
p = y + y*c*(1-tm)/tm
o = y + y*c*(th-tm)/tm
n = y - (y*c)
else:
p = y + (1-y)*c
o = y + (1-y)*c*(th-tm)/(1-tm)
n = y - (1-y)*c*tm/(1-tm)
# Back to RGB order
if h < 1: r, g, b = p, o, n
elif h < 2: r, g, b = o, p, n
elif h < 3: r, g, b = n, p, o
elif h < 4: r, g, b = n, o, p
elif h < 5: r, g, b = o, n, p
else: r, g, b = p, n, o
return ColorPlus([255*r, 255*g, 255*b])
def to_hsv(self):
r, g, b = self.to_floats()
eps = 0.001
if abs(max(r,g,b)) < eps:
return (0,0,0)
return colorsys.rgb_to_hsv(r, g, b)
@staticmethod
def from_hsv(h, s, v):
r, g, b = colorsys.hsv_to_rgb(h, s, v)
return ColorPlus([255*r, 255*g, 255*b])
# TODO: everything below is not updated yet, maybe not really needed
def hex(self):
r,g,b = self.getRGB()
return "#{:02x}{:02x}{:02x}".format(r,g,b)
def getRgbString(self):
r,g,b = self.getRGB()
return "rgb({}, {}, {})".format(r,g,b)
def getHsvString(self):
h,s,v = self.getHSV()
return "hsv({}, {}, {})".format(h,s,v)
def invert(self):
r, g, b = self._rgb
return Color(255-r, 255-g, 255-b)
def darker(clr, q):
h,s,v = clr.getHSV()
v = clip(v-q)
return hsv(h,s,v)
def lighter(clr, q):
h,s,v = clr.getHSV()
v = clip(v+q)
return hsv(h,s,v)
def saturate(clr, q):
h,s,v = clr.getHSV()
s = clip(s+q)
return hsv(h,s,v)
def desaturate(clr, q):
h,s,v = clr.getHSV()
s = clip(s-q)
return hsv(h,s,v)
def increment_hue(clr, q):
h,s,v = clr.getHSV()
h += q
if h > 1.0:
h -= 1.0
if h < 1.0:
h += 1.0
return hsv(h,s,v)
def contrast(clr, q):
h,s,v = clr.getHSV()
v = (v - 0.5)*(1.0 + q) + 0.5
v = clip(v)
return hsv(h,s,v)
def linear(x, y, q):
return (1.-q)*x + q*y
def linear3(v1, v2, q):
x1, y1, z1 = v1
x2, y2, z2 = v2
return (linear(x1, x2, q), linear(y1, y2, q), linear(z1, z2, q))
def circular(h1, h2, q, circle=1.0):
#print("Src hues: "+ str((h1, h2)))
d = h2 - h1
if h1 > h2:
h1, h2 = h2, h1
d = -d
q = 1.0 - q
if d > circle/2.0:
h1 = h1 + circle
h = linear(h1, h2, q)
else:
h = h1 + q*d
if h >= circle:
h -= circle
#print("Hue: "+str(h))
return h

View File

@ -0,0 +1,139 @@
#!/usr/bin/env python
# coding=utf-8
#
# Copyright (C) 2009-2018 Ilya Portnov <portnov84@rambler.ru>
# (original 'palette-editor' tool, version 0.0.7)
# 2020 Maren Hachmann (extension-ification)
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from os.path import join, basename
from lxml import etree as ET
from zipfile import ZipFile, ZIP_DEFLATED
class PaletteFile(object)
def __init__(self, name, colors, filename, folder):
self.colors = colors
if name != "":
self.name = name
else:
self.name = "Palette"
self.folder = folder
def save(self, format):
FORMATS = {'gimp': [build_gpl, 'gpl'],
'scribus': [build_scribus_xml, 'xml'],
'krita': [build_kpl, 'kpl'],
'css': [build_css, 'css'],
'android_xml': [build_android_xml, 'xml'],
}
if os.path.exists(self.folder):
# save with given file name
pass
def build_gpl(self):
# TODO: fix
palette_string = (u"Name: {}\n".format(self.palette.name).encode('utf-8'))
if hasattr(self.palette, 'ncols') and self.palette.ncols:
palette_string += 'Columns: %s\n' % self.palette.ncols
for key,value in self.palette.meta.items():
if key != "Name":
palette_string += u"# {}: {}\n".format(key, value).encode('utf-8')
palette_string += '#\n'
for row in self.palette.slots:
for slot in row:
n = slot.name
r, g, b = slot.color.getRGB()
s = '%d %d %d %s\n' % (r, g, b, n)
palette_string += s
for key,value in slot.color.meta.items():
if key != "Name":
palette_string += u"# {}: {}\n".format(key, value).encode('utf-8')
return palette_string
def build_kpl(self):
# TODO: fix (and don't save it here, only build)
with ZipFile(file_w, 'w', ZIP_DEFLATED) as zf:
zf.writestr("mimetype", MIMETYPE)
xml = ET.Element("Colorset")
xml.attrib['version'] = '1.0'
xml.attrib['columns'] = str(self.palette.ncols)
xml.attrib['name'] = self.palette.name
xml.attrib['comment'] = self.palette.meta.get("Comment", "Generated by Palette Editor")
for i,row in enumerate(self.palette.slots):
for j,slot in enumerate(row):
color = slot.color
name = color.name
default_name = "Swatch-{}-{}".format(i,j)
if not name:
name = default_name
elem = ET.SubElement(xml, "ColorSetEntry")
elem.attrib['spot'] = color.meta.get("Spot", "false")
elem.attrib['id'] = default_name
elem.attrib['name'] = name
elem.attrib['bitdepth'] = 'U8'
r,g,b = color.getRGB1()
srgb = ET.SubElement(elem, "sRGB")
srgb.attrib['r'] = str(r)
srgb.attrib['g'] = str(g)
srgb.attrib['b'] = str(b)
tree = ET.ElementTree(xml)
tree_str = ET.tostring(tree, encoding='utf-8', pretty_print=True, xml_declaration=False)
zf.writestr("colorset.xml", tree_str)
def build_scribus_xml(self):
# TODO: fix, and don't save here
xml = ET.Element("SCRIBUSCOLORS", NAME=name)
for i,row in enumerate(self.palette.getColors()):
for j,color in enumerate(row):
name = color.name
if not name:
name = "Swatch-{}-{}".format(i,j)
elem = ET.SubElement(xml, "COLOR", NAME=name, RGB=color.hex())
if "Spot" in color.meta:
elem.attrib["Spot"] = color.meta["Spot"]
if "Register" in color.meta:
elem.attrib["Register"] = color.meta["Register"]
ET.ElementTree(xml).write(file_w, encoding="utf-8", pretty_print=True, xml_declaration=True)
def build_android_xml(self):
# https://stackoverflow.com/questions/3769762/web-colors-in-an-android-color-xml-resource-file
palette_string = ''
# TODO: implement
return palette_string
def build_css(self):
palette_string = ''
# TODO: fix
for i, row in enumerate(self.palette.slots):
for j, slot in enumerate(row):
hex = slot.color.hex()
s = ".color-{}-{}{} {{ color: {} }};\n".format(i,j, user, hex)
palette_string += s
return palette_string

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python
# coding=utf-8
#
# Copyright (C) 2009-2018 Ilya Portnov <portnov84@rambler.ru>
# (original 'palette-editor' tool, version 0.0.7)
# 2020 Maren Hachmann (extension-ification)
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from math import sqrt, sin, cos
from color_harmony.colorplus import ColorPlus
from color_harmony.utils import seq, circle_hue
# Each of these functions takes one ColorPlus object
# and returns a list of ColorPlus objects:
__all__ = ['opposite', 'splitcomplementary', 'similar', 'similarAndOpposite', 'rectangle', 'nHues', 'fiveColors']
# Harmony functions
# 180° rotation
def opposite(color):
h, s, v = color.to_hsv()
h = h + 0.5
if h > 1.0:
h -= 1.0
return [color, ColorPlus.from_hsv(h, s, v)]
# default value 0.5 corresponds to +-36° deviation from opposite, max. useful value: 89°, min. useful value: 1°
def splitcomplementary(color, parameter=0.5):
h, s, v = color.to_hsv()
h += (1.0 - 0.4*parameter)/2.0
if h > 1.0:
h -= 1.0
c1 = ColorPlus.from_hsv(h,s,v)
h += 0.4*parameter
if h > 1.0:
h -= 1.0
c2 = ColorPlus.from_hsv(h,s,v)
return [color, c1, c2]
# default value 0.5 corresponds to 36° per step, max. useful value: 360°/n , min. useful value: 1°
def similar(color, n, parameter=0.5):
h, s, v = color.to_hsv()
step = 0.2 * parameter
hmin = h - (n // 2) * step
hmax = h + (n // 2) * step
return [ColorPlus.from_hsv(dh % 1.0, s, v) for dh in seq(hmin, hmax, step)]
# default value 0.5 corresponds to 36° deviation from original, max. useful value: 178°, min. useful value: 1°
def similarAndOpposite(color, parameter=0.5):
h, c, y = color.to_hcy()
h1 = h + 0.2 * parameter
if h1 > 1.0:
h1 -= 1.0
h2 = h - 0.2 * parameter
if h2 < 0.0:
h2 += 1.0
h3 = h + 0.5
if h3 > 1.0:
h3 -= 1.0
return [ColorPlus.from_hcy(h1,c,y),
color,
ColorPlus.from_hcy(h2,c,y),
ColorPlus.from_hcy(h3,c,y)]
# default value 0.5 corresponds to 36° deviation from original, max. useful angle 180°, min. useful angle 1°
def rectangle(color, parameter=0.5):
h, c, y = color.to_hcy()
h1 = (h + 0.2 * parameter) % 1.0
h2 = (h1 + 0.5) % 1.0
h3 = (h + 0.5) % 1.0
return [color,
ColorPlus.from_hcy(h1,c,y),
ColorPlus.from_hcy(h2,c,y),
ColorPlus.from_hcy(h3,c,y)]
# returns n colors that are placed on the hue circle in steps of 360°/n
def nHues(color, n):
h, s, v = color.to_hsv()
return [color] + [ColorPlus.from_hsv(circle_hue(i, n, h), s, v) for i in range(1, n)]
# parameter determines +/- deviation from a the hues +/-120° away, default value 0.5 corresponds to 2.16°, max. possible angle 4.32°
def fiveColors(color, parameter=0.5):
h0, s, v = color.to_hsv()
h1s = (h0 + 1.0/3.0) % 1.0
h2s = (h1s + 1.0/3.0) % 1.0
delta = 0.06 * parameter
h1 = (h1s - delta) % 1.0
h2 = (h1s + delta) % 1.0
h3 = (h2s - delta) % 1.0
h4 = (h2s + delta) % 1.0
return [color] + [ColorPlus.from_hsv(h,s,v) for h in [h1,h2,h3,h4]]

View File

@ -0,0 +1,117 @@
from color.colors import *
from color.spaces import *
def find_min(idx, occupied, d):
best_i = None
best_y = None
best_clr = None
for i, clr in d.iteritems():
if i in occupied:
continue
y = clr[idx]
if best_y is None or y < best_y:
best_i = i
best_y = y
best_clr = clr
if best_y is not None:
return best_i
for i, clr in d.iteritems():
y = clr[idx]
if best_y is None or y < best_y:
best_i = i
best_y = y
best_clr = clr
assert best_i is not None
return best_i
def find_max(idx, occupied, d):
best_i = None
best_y = None
best_clr = None
for i, clr in d.iteritems():
if i in occupied:
continue
y = clr[idx]
if best_y is None or y > best_y:
best_i = i
best_y = y
best_clr = clr
if best_y is not None:
return best_i
for i, clr in d.iteritems():
y = clr[idx]
if best_y is None or y > best_y:
best_i = i
best_y = y
best_clr = clr
assert best_i is not None
return best_i
def match_colors(colors1, colors2):
hsvs1 = dict(enumerate([c.getHCY() for c in colors1 if c is not None]))
hsvs2 = dict(enumerate([c.getHCY() for c in colors2 if c is not None]))
occupied = []
result = {}
while len(hsvs1.keys()) > 0:
# Darkest of SVG colors
darkest1_i = find_min(2, [], hsvs1)
# Darkest of palette colors
darkest2_i = find_min(2, occupied, hsvs2)
hsvs1.pop(darkest1_i)
occupied.append(darkest2_i)
result[darkest1_i] = darkest2_i
if not hsvs1:
break
# Lightest of SVG colors
lightest1_i = find_max(2, [], hsvs1)
# Lightest of palette colors
lightest2_i = find_max(2, occupied, hsvs2)
hsvs1.pop(lightest1_i)
occupied.append(lightest2_i)
result[lightest1_i] = lightest2_i
if not hsvs1:
break
# Less saturated of SVG colors
grayest1_i = find_min(1, [], hsvs1)
# Less saturated of palette colors
grayest2_i = find_min(1, occupied, hsvs2)
hsvs1.pop(grayest1_i)
occupied.append(grayest2_i)
result[grayest1_i] = grayest2_i
if not hsvs1:
break
# Most saturated of SVG colors
saturated1_i = find_max(1, [], hsvs1)
# Most saturated of palette colors
saturated2_i = find_max(1, occupied, hsvs2)
hsvs1.pop(saturated1_i)
occupied.append(saturated2_i)
result[saturated1_i] = saturated2_i
if not hsvs1:
break
redest1_i = find_min(0, [], hsvs1)
redest2_i = find_min(0, occupied, hsvs2)
hsvs1.pop(redest1_i)
occupied.append(redest2_i)
result[redest1_i] = redest2_i
if not hsvs1:
break
bluest1_i = find_max(0, [], hsvs1)
bluest2_i = find_max(0, occupied, hsvs2)
hsvs1.pop(bluest1_i)
occupied.append(bluest2_i)
result[bluest1_i] = bluest2_i
clrs = []
for i in range(len(result.keys())):
j = result[i]
clrs.append(colors2[j])
return clrs

View File

@ -0,0 +1,96 @@
import re
from lxml import etree
from color import colors
SVG_NS="http://www.w3.org/2000/svg"
color_re = re.compile("#[0-9a-fA-F]+")
def walk(processor, element):
for child in element.iter():
processor(child)
class Collector(object):
def __init__(self):
self.colors = {}
self.n = 0
def _parse(self, string):
xs = string.split(";")
single = len(xs) == 1
result = {}
for x in xs:
ts = x.split(":")
if len(ts) < 2:
if single:
return None
else:
continue
key, value = ts[0], ts[1]
result[key] = value
return result
def _merge(self, attr):
if type(attr) == str:
return attr
result = ""
for key in attr:
value = attr[key]
result += key + ":" + value + ";"
return result
def _is_color(self, val):
return color_re.match(val) is not None
def _remember_color(self, color):
if color not in self.colors:
self.colors[color] = self.n
self.n += 1
n = self.colors[color]
return "${color%s}" % n
def _process_attr(self, value):
d = self._parse(value)
if d is None:
if self._is_color(value):
return self._remember_color(value)
else:
return value
elif type(d) == dict:
for attr in ['fill', 'stroke', 'stop-color']:
if (attr in d) and self._is_color(d[attr]):
color = d[attr]
d[attr] = self._remember_color(color)
return self._merge(d)
else:
if self._is_color(value):
return self._remember_color(value)
else:
return value
def process(self, element):
for attr in ['fill', 'stroke', 'style', 'pagecolor', 'bordercolor']:
if attr in element.attrib:
value = element.get(attr)
element.set(attr, self._process_attr(value))
def result(self):
return self.colors
def read_template(filename):
xml = etree.parse(filename)
collector = Collector()
walk(collector.process, xml.getroot())
svg = etree.tostring(xml, encoding='utf-8', xml_declaration=True, pretty_print=True)
#open("last_template.svg",'w').write(svg)
color_dict = collector.result()
colors_inv = dict((v,k) for k, v in color_dict.iteritems())
svg_colors = []
for key in range(len(colors_inv.keys())):
clr = colors_inv[key]
svg_colors.append( colors.fromHex(clr) )
return svg_colors, svg

View File

@ -0,0 +1,135 @@
from string import Template
from PyQt4 import QtGui, QtSvg, QtCore
from color.colors import *
from color.spaces import *
import svg, transform, matching
class SvgTemplateWidget(QtSvg.QSvgWidget):
template_loaded = QtCore.pyqtSignal()
colors_matched = QtCore.pyqtSignal()
file_dropped = QtCore.pyqtSignal(unicode)
def __init__(self, *args):
QtSvg.QSvgWidget.__init__(self, *args)
self.setAcceptDrops(True)
self._colors = [Color(i*10,i*10,i*10) for i in range(20)]
self._template = None
self._template_filename = None
self._svg = None
self._need_render = True
self._svg_colors = None
self._dst_colors = None
self._last_size = None
def sizeHint(self):
if self.renderer().isValid():
return self.renderer().defaultSize()
elif self._last_size:
return self._last_size
else:
return QtCore.QSize(300,300)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
if event.mimeData().hasUrls():
urls = event.mimeData().urls()
path = unicode( urls[0].path() )
self.file_dropped.emit(path)
def _get_color(self, i):
if i < len(self._colors):
return self._colors[i]
else:
return Color(i*10, i*10, i*10)
def _update(self):
arr = QtCore.QByteArray.fromRawData(self.get_svg())
print("Data loaded: {} bytes".format(arr.length()))
self.load(arr)
if self.renderer().isValid():
self._last_size = self.renderer().defaultSize()
self.update()
def _get_image(self):
w,h = self.size().width(), self.size().height()
image = QtGui.QImage(w, h, QtGui.QImage.Format_ARGB32_Premultiplied)
image.fill(0)
qp = QtGui.QPainter()
qp.begin(image)
self.renderer().render(qp, QtCore.QRectF(0.0, 0.0, w, h))
qp.end()
return image
def loadTemplate(self, filename):
self._template_filename = filename
self._svg_colors, self._template = svg.read_template(filename)
print("Source SVG colors:")
for c in self._svg_colors:
print str(c)
print("Template loaded: {}: {} bytes".format(filename, len(self._template)))
self._need_render = True
self._update()
self.template_loaded.emit()
def set_color(self, idx, color):
self._colors[idx] = color
self._need_render = True
self._update()
def setColors(self, dst_colors, space=HCY):
if not dst_colors:
return
print("Matching colors in space: {}".format(space))
self._dst_colors = dst_colors
self._colors = transform.match_colors(space, self._svg_colors, dst_colors)
#self._colors = matching.match_colors(self._svg_colors, dst_colors)
self._need_render = True
self._update()
self.colors_matched.emit()
def resetColors(self):
self.load(self._template_filename)
self.repaint()
def get_svg_colors(self):
return self._svg_colors
def get_dst_colors(self):
return self._colors
def get_svg(self):
if self._svg is not None and not self._need_render:
return self._svg
else:
self._svg = self._render()
self._need_render = False
return self._svg
def _render(self):
#d = dict([("color"+str(i), color.hex() if color is not None else Color(255,255,255)) for i, color in enumerate(self._colors)])
d = ColorDict(self._colors)
#self._image = self._get_image()
return Template(self._template).substitute(d)
class ColorDict(object):
def __init__(self, colors):
self._colors = colors
def __getitem__(self, key):
if key.startswith("color"):
n = int( key[5:] )
if n < len(self._colors):
return self._colors[n].hex()
else:
return "#ffffff"
else:
raise KeyError(key)

View File

@ -0,0 +1,241 @@
#!/usr/bin/python3
# -y11+a13*x13+a12*x12+a11*x11+b1
# -y12+a23*x13+a22*x12+a21*x11+b2
# -y13+a33*x13+a32*x12+a31*x11+b3
# -y21+a13*x23+a12*x22+a11*x21+b1
# -y22+a23*x23+a22*x22+a21*x21+b2
# -y23+a33*x23+a32*x22+a31*x21+b3
# -y31+a13*x33+a12*x32+a11*x31+b1
# -y32+a23*x33+a22*x32+a21*x31+b2
# -y33+a33*x33+a32*x32+a31*x31+b3
# -y41+a13*x43+a12*x42+a11*x41+b1
# -y42+a23*x43+a22*x42+a21*x41+b2
# -y43+a33*x43+a32*x42+a31*x41+b3
from math import sqrt
import itertools
import numpy as np
from numpy.linalg import solve, det
from numpy.linalg.linalg import LinAlgError
from copy import deepcopy as copy
from PyQt4 import QtGui
from color.colors import *
from color.spaces import *
def get_A(x):
return np.array([[x[0][0], x[0][1], x[0][2], 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, x[0][0], x[0][1], x[0][2], 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, x[0][0], x[0][1], x[0][2], 0, 0, 1],
[x[1][0], x[1][1], x[1][2], 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, x[1][0], x[1][1], x[1][2], 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, x[1][0], x[1][1], x[1][2], 0, 0, 1],
[x[2][0], x[2][1], x[2][2], 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, x[2][0], x[2][1], x[2][2], 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, x[2][0], x[2][1], x[2][2], 0, 0, 1],
[x[3][0], x[3][1], x[3][2], 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, x[3][0], x[3][1], x[3][2], 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, x[3][0], x[3][1], x[3][2], 0, 0, 1] ])
def get_B(y):
return np.array([[y[0][0]], [y[0][1]], [y[0][2]],
[y[1][0]], [y[1][1]], [y[1][2]],
[y[2][0]], [y[2][1]], [y[2][2]],
[y[3][0]], [y[3][1]], [y[3][2]] ])
def color_row(space, color):
x1,x2,x3 = space.getCoords(color)
return np.array([x1,x2,x3])
def color_column(space, color):
x1,x2,x3 = space.getCoords(color)
return np.array([[x1],[x2],[x3]])
def colors_array(space, *colors):
return np.array([color_row(space, c) for c in colors])
def find_transform_colors(space, cx1, cx2, cx3, cx4, cy1, cy2, cy3, cy4):
x = colors_array(space, cx1, cx2, cx3, cx4)
y = colors_array(space, cy1, cy2, cy3, cy4)
return find_transform(x,y)
def find_transform(x, y):
#print("X:\n"+str(x))
#print("Y:\n"+str(y))
m = solve(get_A(x), get_B(y))
a = np.array([[m[0][0], m[1][0], m[2][0]],
[m[3][0], m[4][0], m[5][0]],
[m[6][0], m[7][0], m[8][0]]])
b = np.array([[m[9][0]], [m[10][0]], [m[11][0]]])
return (a, b)
def transform_colors(space, a, b, cx):
x = color_column(space, cx)
y = a.dot(x) + b
#print("X: " + str(x))
#print("Y: " + str(y))
return space.fromCoords(y[0][0], y[1][0], y[2][0])
def transform(a, b, x):
#print(type(x))
return a.dot(x[:,None]) + b
def rhoC(space, color1, color2):
x1,y1,z1 = space.getCoords(color1)
x2,y2,z2 = space.getCoords(color2)
return sqrt((x1-x2)**2 + (y1-y2)**2 + (z1-z2)**2)
def rho(c1, c2):
x1,y1,z1 = c1[0], c1[1], c1[2]
x2,y2,z2 = c2[0], c2[1], c2[2]
return sqrt((x1-x2)**2 + (y1-y2)**2 + (z1-z2)**2)
def get_center(points):
zero = np.array([0,0,0])
return sum(points, zero) / float(len(points))
def get_center_color(space, colors):
zero = np.array([0,0,0])
points = [space.getCoords(c) for c in colors]
center = sum(points, zero) / float(len(points))
c1,c2,c3 = center[0], center[1], center[2]
return space.fromCoords((c1,c2,c3))
def get_nearest(x, occupied, points):
min_rho = None
min_p = None
for p in points:
if any([(p == o).all() for o in occupied]):
continue
r = rho(x,p)
if min_rho is None or r < min_rho:
min_rho = r
min_p = p
if min_p is not None:
occupied.append(min_p)
#print("Now occupied : " + str(x))
return min_p
#print("Was occupied : " + str(x))
for p in points:
r = rho(x,p)
if min_rho is None or r < min_rho:
min_rho = r
min_p = p
return min_p
def get_nearest_color(space, occupied, cx, colors):
points = [space.getCoords(c) for c in colors]
cy = get_nearest(space.getCoords(cx), occupied, points)
return space.fromCoords((cy[0], cy[1], cy[2]))
def exclude(lst, x):
return [p for p in lst if not (p == x).all()]
def get_farest(points):
#center = get_center(points)
#print(str(center))
points_ = copy(points)
darkest = min(points_, key = lambda p: p[2])
points_ = exclude(points_, darkest)
lightest = max(points_, key = lambda p: p[2])
points_ = exclude(points_, lightest)
grayest = min(points_, key = lambda p: p[1])
points_ = exclude(points_, grayest)
most_saturated = max(points_, key = lambda p: p[1])
return [darkest, lightest, grayest, most_saturated]
#srt = sorted(points, key = lambda c: -rho(center, c))
#return srt[:4]
def get_farest_colors(space, colors):
points = [space.getCoords(c) for c in colors]
farest = get_farest(points)
return [space.fromCoords(c) for c in farest]
def match_colors_(space, colors1, colors2):
try:
points1 = [color_row(space, c) for c in colors1 if c is not None]
points2 = [color_row(space, c) for c in colors2 if c is not None]
farest1 = get_farest(points1)
farest2 = get_farest(points2)
a, b = find_transform(farest1, farest2)
#print("A:\n" + str(a))
#print("B:\n" + str(b))
print("Matching colors:")
for p1, p2 in zip(farest1, farest2):
print(" {} -> {}".format(p1,p2))
transformed = [transform(a, b, x) for x in points1]
occupied = []
matched = []
for x in transformed:
y = get_nearest(x, occupied, points2)
matched.append(y)
return [space.fromCoords(x) for x in matched]
except LinAlgError as e:
print e
return colors1
def match_colors(space, colors1, colors2):
points1 = [color_row(space, c) for c in colors1 if c is not None]
points2 = [color_row(space, c) for c in colors2 if c is not None]
c1 = get_center(points1)
c2 = get_center(points2)
#print c1, c2
print("Centers difference: {}".format(c2-c1))
szs1 = np.vstack([abs(c1-p) for p in points1]).max(axis=0)
szs2 = np.vstack([abs(c2-p) for p in points2]).max(axis=0)
if (szs1 == 0).any():
zoom = np.array([1,1,1])
else:
zoom = szs2 / szs1
print("Scale by axes: {}".format(zoom))
transformed = [(p-c1)*zoom + c2 for p in points1]
occupied = []
matched = []
deltas = []
for x in transformed:
y = get_nearest(x, occupied, points2)
matched.append(y)
deltas.append(abs(y-x))
delta = np.vstack(deltas).max(axis=0)
print("Maximum deltas from affine transformed to source colors: {}".format(delta))
return [space.fromCoords(x) for x in matched]
def find_simple_transform(space, colors1, colors2):
points1 = [color_row(space, c) for c in colors1 if c is not None]
points2 = [color_row(space, c) for c in colors2 if c is not None]
c1 = get_center(points1)
c2 = get_center(points2)
#transfer = c2 - c1
szs1 = max([abs(c1-p) for p in points1])
szs2 = max([abs(c2-p) for p in points2])
if (szs1 == 0).any():
zoom = np.array([1,1,1])
else:
zoom = szs2 / szs1
return (c1, c2, zoom)
if __name__ == "__main__":
x1 = Color(0,0,0)
x2 = Color(0,10,0)
x3 = Color(0,0,20)
x4 = Color(20,0,10)
x5 = Color(10,10,10)
x6 = Color(0,20,0)
x7 = Color(0,0,40)
x8 = Color(40,0,20)
y1 = Color(0,0,0)
y2 = Color(0,10,0)
y3 = Color(0,0,20)
y4 = Color(20,0,10)
y5 = Color(10,10,10)
y6 = Color(0,20,0)
y7 = Color(0,0,40)
y8 = Color(40,0,20)
res = match_colors(RGB, [x1,x2,x3,x4,x5,x6,x7,x8], [y1,y2,y3,y4,y5,y6,y7,y8])
print([c.getRGB() for c in res])

View File

@ -0,0 +1,17 @@
def load_palette(filename, mixer=None, options=None):
if mixer is None:
mixer = mixers.MixerRGB
loader = detect_storage(filename)
if loader is None:
return None
palette = loader().load(mixer, filename, options)
return palette
def save_palette(palette, path, formatname=None):
if formatname is not None:
loader = get_storage_by_name(formatname)
else:
loader = detect_storage(path, save=True)
if loader is None:
raise RuntimeError("Unknown file type!")
loader(palette).save(path)

View File

@ -0,0 +1,352 @@
from os.path import join, basename
from color.colors import *
from color import mixers
from models.meta import Meta
NONE = 0
USER_DEFINED = 1
VERTICALLY_GENERATED = 2
HORIZONTALLY_GENERATED = 3
DEFAULT_GROUP_SIZE = 7
MAX_COLS = 10
class Slot(object):
def __init__(self, color=None, name=None, user_defined=False):
self._color = color
self._mode = NONE
self._user_defined = user_defined
self._src_slot1 = None
self._src_row1 = None
self._src_col1 = None
self._src_slot2 = None
self._src_row2 = None
self._src_col2 = None
if name:
self.name = name
def get_name(self):
if self._color:
return self._color.name
else:
return None
def set_name(self, name):
if self._color:
self._color.name = name
name = property(get_name, set_name)
def __repr__(self):
return "<Slot mode={}>".format(self._mode)
def getColor(self):
if self._color is None:
return Color(1,1,1)
else:
return self._color
def setColor(self, color):
self._color = color
color = property(getColor, setColor)
def getMode(self):
if self._user_defined:
return USER_DEFINED
else:
return self._mode
def setMode(self, mode):
self._mode = mode
if mode == USER_DEFINED:
self._user_defined = True
self._mode = NONE
mode = property(getMode, setMode)
def mark(self, user_defined=None):
if user_defined is None:
user_defined = not self._user_defined
#print("Mark: " + str(user_defined))
self._user_defined = user_defined
def setSources(self, slot1, row1, col1, slot2, row2, col2):
self._src_slot1 = slot1
self._src_row1 = row1
self._src_col1 = col1
self._src_slot2 = slot2
self._src_row2 = row2
self._src_col2 = col2
#print("Sources: ({},{}) and ({},{})".format(row1,col1, row2,col2))
def getSource1(self):
return self._src_slot1, self._src_row1, self._src_col1
def getSource2(self):
return self._src_slot2, self._src_row2, self._src_col2
class Palette(object):
def __init__(self, mixer, nrows=1, ncols=7):
self.mixer = mixer
self.nrows = nrows
self.ncols = ncols
self.slots = [[Slot() for i in range(ncols)] for j in range(nrows)]
self.need_recalc_colors = True
self.meta = Meta()
def get_name(self):
return self.meta.get("Name", "Untited")
def set_name(self, name):
self.meta["Name"] = name
name = property(get_name, set_name)
def mark_color(self, row, column, mark=None):
print("Marking color at ({}, {})".format(row,column))
slot = self.slots[row][column]
slot.mark(mark)
self.need_recalc_colors = True
self.recalc()
def setMixer(self, mixer):
self.mixer = mixer
self.recalc()
def del_column(self, col):
if self.ncols < 2:
return
new = []
for row in self.slots:
new_row = []
for c, slot in enumerate(row):
if c != col:
new_row.append(slot)
new.append(new_row)
self.slots = new
self.ncols -= 1
self.recalc()
def add_column(self, col):
new = []
for row in self.slots:
new_row = []
for c, slot in enumerate(row):
if c == col:
new_row.append(Slot())
new_row.append(slot)
new.append(new_row)
self.slots = new
self.ncols += 1
self.recalc()
def del_row(self, row):
if self.nrows < 2:
return
new = []
for r,row_ in enumerate(self.slots):
if r != row:
new.append(row_)
self.slots = new
self.nrows -= 1
self.recalc()
def add_row(self, row):
new = []
for r,row_ in enumerate(self.slots):
if r == row:
new_row = [Slot() for k in range(self.ncols)]
new.append(new_row)
new.append(row_)
self.slots = new
self.nrows += 1
self.recalc()
def paint(self, row, column, color):
self.slots[row][column].color = color
self.slots[row][column].mark(True)
#self.recalc()
def erase(self, row, column):
self.paint(row, column, Color(0,0,0))
def getUserDefinedSlots(self):
"""Returns list of tuples: (row, column, slot)."""
result = []
for i,row in enumerate(self.slots):
for j,slot in enumerate(row):
if slot.mode == USER_DEFINED:
result.append((i,j,slot))
return result
def getColor(self, row, column):
if self.need_recalc_colors:
self.recalc()
try:
return self.slots[row][column].color
except IndexError:
#print("Cannot get color ({},{}): size is ({},{})".format(row,column, self.nrows, self.ncols))
return Color(255,255,255)
def getColors(self):
if self.need_recalc_colors:
self.recalc()
return [[slot.color for slot in row] for row in self.slots]
def setSlots(self, all_slots):
m = len(all_slots) % self.ncols
if m != 0:
for i in range(self.ncols - m):
all_slots.append(Slot(Color(255,255,255)))
self.slots = []
row = []
for i, slot in enumerate(all_slots):
if i % self.ncols == 0:
if len(row) != 0:
self.slots.append(row)
row = []
row.append(slot)
self.slots.append(row)
self.nrows = len(self.slots)
self.need_recalc_colors = True
self.recalc()
# print(self.slots)
def user_chosen_slot_down(self, i,j):
"""Returns tuple:
* Is slot found
* Row of found slot
* Column of found slot"""
#print("Searching down ({},{})".format(i,j))
for i1 in range(i, self.nrows):
try:
slot = self.slots[i1][j]
#print("Down: check ({},{}): {}".format(i1,j,slot))
if slot.mode == USER_DEFINED:
#print("Found down ({},{}): ({},{})".format(i,j, i1,j))
return True,i1,j
except IndexError:
print("Cannot get slot at ({}, {})".format(i,j))
return False, self.nrows-1,j
return False, self.nrows-1,j
def user_chosen_slot_up(self, i,j):
"""Returns tuple:
* Is slot found
* Row of found slot
* Column of found slot"""
for i1 in range(i-1, -1, -1):
if self.slots[i1][j].mode == USER_DEFINED:
#print("Found up ({},{}): ({},{})".format(i,j, i1,j))
return True,i1,j
return False, 0, j
def fixed_slot_right(self, i,j):
"""Returns tuple:
* Mode of found slot
* Row of found slot
* Column of found slot"""
for j1 in range(j, self.ncols):
if self.slots[i][j1].mode in [USER_DEFINED, VERTICALLY_GENERATED]:
return self.slots[i][j1].mode, i,j1
return NONE, i,self.ncols-1
def fixed_slot_left(self, i,j):
"""Returns tuple:
* Mode of found slot
* Row of found slot
* Column of found slot"""
for j1 in range(j-1, -1, -1):
if self.slots[i][j1].mode in [USER_DEFINED, VERTICALLY_GENERATED]:
return self.slots[i][j1].mode, i,j1
return NONE, i,0
def recalc(self):
self._calc_modes()
self._calc_modes()
self._calc_modes()
self._calc_colors()
self.need_recalc_colors = False
def _calc_modes(self):
for i,row in enumerate(self.slots):
for j,slot in enumerate(row):
if slot.mode == USER_DEFINED:
continue
# Should slot be vertically generated?
v1,iv1,jv1 = self.user_chosen_slot_down(i,j)
v2,iv2,jv2 = self.user_chosen_slot_up(i,j)
h1,ih1,jh1 = self.fixed_slot_left(i,j)
h2,ih2,jh2 = self.fixed_slot_right(i,j)
if v1 and v2: # if there are user chosen slots above and below current
slot.mode = VERTICALLY_GENERATED
s1 = self.slots[iv1][jv1]
s2 = self.slots[iv2][jv2]
slot.setSources(s1, iv1, jv1, s2, iv2, jv2)
elif ((v1 and j-jv1 > 1) or (v2 and jv2-j > 1)) and ((h1!=USER_DEFINED) or (h2!=USER_DEFINED)):
slot.mode = VERTICALLY_GENERATED
s1 = self.slots[iv1][jv1]
s2 = self.slots[iv2][jv2]
slot.setSources(s1, iv1, jv1, s2, iv2, jv2)
elif h1 and h2:
# if there are fixed slots at left and at right of current
slot.mode = HORIZONTALLY_GENERATED
s1 = self.slots[ih1][jh1]
s2 = self.slots[ih2][jh2]
slot.setSources(s1, ih1, jh1, s2, ih2, jh2)
elif (h1 or h2) and not (v1 or v2):
slot.mode = HORIZONTALLY_GENERATED
s1 = self.slots[ih1][jh1]
s2 = self.slots[ih2][jh2]
slot.setSources(s1, ih1, jh1, s2, ih2, jh2)
else:
slot.mode = HORIZONTALLY_GENERATED
s1 = self.slots[ih1][jh1]
s2 = self.slots[ih2][jh2]
slot.setSources(s1, ih1, jh1, s2, ih2, jh2)
def color_transition(self, from_color, to_color, steps, idx):
if self.mixer is None:
return Color(1,1,1)
q = float(idx+1) / float(steps+1)
return self.mixer.mix(from_color, to_color, q)
def _calc_colors(self):
for i,row in enumerate(self.slots):
for j,slot in enumerate(row):
if slot.mode == VERTICALLY_GENERATED:
slot_down, iv1, jv1 = slot.getSource1()
slot_up, iv2, jv2 = slot.getSource2()
clr_down = slot_down.color
clr_up = slot_up.color
length = iv1-iv2 - 1
idx = i-iv2 - 1
try:
#print("Mixing ({},{}) with ({},{}) to get ({},{})".format(iv1,jv1, iv2,jv2, i,j))
clr = self.color_transition(clr_up,clr_down,length, idx)
except IndexError:
clr = Color(1,1,1)
slot.color = clr
for i,row in enumerate(self.slots):
for j,slot in enumerate(row):
if slot.mode == HORIZONTALLY_GENERATED:
slot_left, ih1, jh1 = slot.getSource1()
slot_right, ih2, jh2 = slot.getSource2()
clr_left = slot_left.color
clr_right = slot_right.color
length = jh2-jh1 - 1
idx = j-jh1 - 1
try:
#print("Mixing ({},{}) with ({},{}) to get ({},{})".format(ih1,jh1, ih2,jh2, i,j))
clr = self.color_transition(clr_left,clr_right,length, idx)
except IndexError:
clr = Color(1,1,1)
slot.color = clr

View File

@ -0,0 +1,125 @@
#!/usr/bin/env python
# coding=utf-8
#
# Copyright (C) 2009-2018 Ilya Portnov <portnov84@rambler.ru>
# (original 'palette-editor' tool, version 0.0.7)
# 2020 Maren Hachmann (extension-ification)
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from color_harmony.colorplus import ColorPlus
from color_harmony.utils import clip, seq, variate
# Shading functions
# 4 cooler colors
def cooler(color, parameter):
h,s,v = color.to_hsv()
if h < 1.0/6.0:
sign = -1.0
elif h > 2.0/3.0:
sign = -1.0
else:
sign = 1.0
step = 0.1 * parameter
result = []
for i in range(4):
h += sign * step
if h > 1.0:
h -= 1.0
elif h < 0.0:
h += 1.0
result.append(ColorPlus.from_hsv(h, s, v))
return result
# 4 warmer colors
def warmer(color, parameter):
h,s,v = color.to_hsv()
if h < 1.0/6.0:
sign = +1.0
elif h > 2.0/3.0:
sign = +1.0
else:
sign = -1.0
step = 0.1 * parameter
result = []
for i in range(4):
h += sign * step
if h > 1.0:
h -= 1.0
elif h < 0.0:
h += 1.0
result.append(ColorPlus.from_hsv(h, s, v))
return result
# returns 2 less saturated and 2 more saturated colors
def saturation(color, parameter):
h, s, v = color.to_hsv()
ss = [clip(x) for x in variate(s, 0.6*parameter, 1.2*parameter)]
# we don't want another copy of the original color
del ss[2]
return [ColorPlus.from_hsv(h, s, v) for s in ss]
# 2 colors with higher value, and 2 with lower
def value(color, parameter):
h, s, v = color.to_hsv()
vs = [clip(x) for x in variate(v, 0.4*parameter, 0.8*parameter)]
del vs[2]
return [ColorPlus.from_hsv(h, s, v) for v in vs]
# 2 colors with higher chroma, and 2 with lower
def chroma(color, parameter):
h, c, y = color.to_hcy()
cs = [clip(x) for x in variate(c, 0.4*parameter, 0.8*parameter)]
del cs[2]
return [ColorPlus.from_hcy(h, c, y) for c in cs]
# 2 colors with higher luma, and 2 with lower
def luma(color, parameter):
h, c, y = color.to_hcy()
ys = [clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)]
del ys[2]
return [ColorPlus.from_hcy(h, c, y) for y in ys]
# 2 colors with hue rotated to the left, and 2 rotated to the right
def hue(color, parameter):
h, c, y = color.to_hcy()
hs = [clip(x) for x in variate(h, 0.15*parameter, 0.3*parameter)]
del hs[2]
return [ColorPlus.from_hcy(h, c, y) for h in hs]
def hue_luma(color, parameter):
h, c, y = color.to_hcy()
hs = [clip(x) for x in variate(h, 0.15*parameter, 0.3*parameter)]
ys = [clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)]
del ys[2]
del hs[2]
return [ColorPlus.from_hcy(h, c, y) for h,y in zip(hs, ys)]
def luma_plus_chroma(color, parameter):
h, c, y = color.to_hcy()
cs = [clip(x) for x in variate(c, 0.4*parameter, 0.8*parameter)]
ys = [clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)]
del cs[2]
del ys[2]
return [ColorPlus.from_hcy(h, c, y) for c,y in zip(cs, ys)]
def luma_minus_chroma(color, parameter):
h, c, y = color.to_hcy()
cs = [clip(x) for x in variate(c, 0.4*parameter, 0.8*parameter)]
ys = list(reversed([clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)]))
del cs[2]
del ys[2]
return [ColorPlus.from_hcy(h, c, y) for c,y in zip(cs, ys)]

View File

@ -0,0 +1,180 @@
from math import sqrt, isnan
import numpy as np
try:
from sklearn.cluster import MeanShift, estimate_bandwidth
from sklearn.utils import shuffle
cluster_analysis_available = True
print("Cluster analysis is available")
except ImportError:
cluster_analysis_available = False
print("Cluster analysis is not available")
try:
import Image
pil_available = True
print("PIL is available")
except ImportError:
print("PIL is not available")
try:
from PIL import Image
print("Pillow is available")
pil_available = True
except ImportError:
print("Neither PIL or Pillow are available")
pil_available = False
from color.colors import *
if pil_available:
def imread(filename):
img = Image.open(filename)
class Box(object):
def __init__(self, arr):
self._array = arr
def population(self):
return self._array.size
def axis_size(self, idx):
slice_ = self._array[:, idx]
M = slice_.max()
m = slice_.min()
return M - m
def biggest_axis(self):
sizes = [self.axis_size(i) for i in range(3)]
return max(range(3), key = lambda i: sizes[i])
def mean(self):
return self._array.mean(axis=0)
def mean_color(self):
size = self._array.size
if not size:
return None
xs = self._array.mean(axis=0)
x,y,z = xs[0], xs[1], xs[2]
if isnan(x) or isnan(y) or isnan(z):
return None
return Color(int(x), int(y), int(z))
def div_pos(self, idx):
slice_ = self._array[:, idx]
M = slice_.max()
m = slice_.min()
return (m+M)/2.0
def divide(self):
axis = self.biggest_axis()
q = self.div_pos(axis)
idxs = self._array[:, axis] > q
smaller = self._array[~idxs]
bigger = self._array[idxs]
self._array = smaller
return Box(bigger)
if pil_available and cluster_analysis_available:
# Use Means Shift algorithm for cluster analysis
def cluster_analyze(filename, N=1000):
image = imread(filename)
w,h,d = tuple(image.shape)
image_array = np.array( np.reshape(image, (w * h, d)), dtype=np.float64 )
#if image.dtype == 'uint8':
# image_array = image_array / 255.0
image_array_sample = shuffle(image_array, random_state=0)[:N]
bandwidth = estimate_bandwidth(image_array_sample, quantile=0.01, n_samples=500)
ms = MeanShift(bandwidth=bandwidth, bin_seeding=True)
ms.fit(image_array_sample)
cluster_centers = ms.cluster_centers_
n_clusters = len(cluster_centers)
colors = []
print("Number of clusters: {}".format(n_clusters))
for x in cluster_centers:
#print x
clr = Color()
clr.setRGB1((x[0], x[1], x[2]))
colors.append(clr)
return colors
if pil_available:
# Use very fast algorithm for image analysis, translated from Krita's kis_common_colors_recalculation_runner.cpp
# Do not know exactly why does this algorithm work, but it does.
# Initial (C) Adam Celarek
def bin_divide_colors(filename, N=1<<16, n_clusters=49):
img = Image.open(filename)
if img.mode == 'P':
img = img.convert('RGB')
w,h = img.size
n_pixels = w*h
if n_pixels > N:
ratio = sqrt( float(N) / float(n_pixels) )
w,h = int(w*ratio), int(h*ratio)
img = img.resize((w,h))
image = np.array(img)
w,h,d = tuple(image.shape)
colors = np.array( np.reshape(image, (w * h, d)), dtype=np.float64 )
#if image.dtype == 'uint8':
# colors = colors / 255.0
colors = colors[:,0:3]
boxes = [Box(colors)]
while (len(boxes) < n_clusters * 3/5) and (len(colors) > n_clusters * 3/5):
biggest_box = None
biggest_box_population = None
for box in boxes:
population = box.population()
if population <= 3:
continue
if biggest_box_population is None or (population > biggest_box_population and box.axis_size(box.biggest_axis()) >= 3):
biggest_box = box
biggest_box_population = population
if biggest_box is None or biggest_box.population() <= 3:
break
new_box = biggest_box.divide()
boxes.append(new_box)
while (len(boxes) < n_clusters) and (len(colors) > n_clusters):
biggest_box = None
biggest_box_axis_size = None
for box in boxes:
if box.population() <= 3:
continue
size = box.axis_size(box.biggest_axis())
if biggest_box_axis_size is None or (size > biggest_box_axis_size and size >= 3):
biggest_box = box
biggest_box_axis_size = size
if biggest_box is None or biggest_box.population() <= 3:
break
new_box = biggest_box.divide()
boxes.append(new_box)
result = [box.mean_color() for box in boxes if box.mean_color() is not None]
return result
image_loading_supported = pil_available or cluster_analysis_available
if pil_available:
get_common_colors = bin_divide_colors
use_sklearn = False
elif cluster_analysis_available :
get_common_colors = cluster_analyze
use_sklearn = True

View File

@ -0,0 +1,83 @@
from os.path import join, basename
from math import sqrt, floor
from color.colors import *
from color import mixers
from palette.storage.storage import *
try:
from tinycss import make_parser, color3
css_support = True
except ImportError:
print("TinyCSS is not available")
css_support = False
class CSS(Storage):
name = 'css'
title = _("CSS cascading stylesheet")
filters = ["*.css"]
can_load = css_support
can_save = True
@staticmethod
def check(filename):
return True
def save(self, file_w):
pf = open(file_w, 'w')
for i, row in enumerate(self.palette.slots):
for j, slot in enumerate(row):
user = "-user" if slot.mode == USER_DEFINED else ""
hex = slot.color.hex()
s = ".color-{}-{}{} {{ color: {} }};\n".format(i,j, user, hex)
pf.write(s)
pf.close()
def load(self, mixer, file_r, options=None):
self.palette = Palette(mixer)
self.palette.ncols = None
all_slots = []
colors = []
def add_color(clr):
for c in colors:
if c.getRGB() == clr.getRGB():
return None
colors.append(clr)
return clr
parser = make_parser('page3')
css = parser.parse_stylesheet_file(file_r)
for ruleset in css.rules:
#print ruleset
if ruleset.at_keyword:
continue
for declaration in ruleset.declarations:
#print declaration
for token in declaration.value:
#print token
css_color = color3.parse_color(token)
if not isinstance(css_color, color3.RGBA):
continue
r,g,b = css_color.red, css_color.green, css_color.blue
color = Color()
color.setRGB1((clip(r), clip(g), clip(b)))
color = add_color(color)
if not color:
continue
slot = Slot(color, user_defined=True)
all_slots.append(slot)
n_colors = len(all_slots)
if n_colors > MAX_COLS:
self.palette.ncols = max( int( floor(sqrt(n_colors)) ), 1)
else:
self.palette.ncols = n_colors
self.palette.setSlots(all_slots)
self.palette.meta["SourceFormat"] = "CSS"
print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols ))
return self.palette

View File

@ -0,0 +1,188 @@
from os.path import join, basename
from PyQt4 import QtGui
import re
from color.colors import *
from color import mixers
from palette.storage.storage import *
marker = '# Colors not marked with #USER are auto-generated'
metare = re.compile("^# (\\w+): (.+)")
def save_gpl(name, ncols, clrs, file_w):
if type(file_w) in [str,unicode]:
pf = open(file_w, 'w')
do_close = True
elif hasattr(file_w,'write'):
pf = file_w
do_close = False
else:
raise ValueError("Invalid argument type in save_gpl: {}".format(type(file_w)))
pf.write('GIMP Palette\n')
pf.write(u"Name: {}\n".format(name).encode('utf-8'))
if ncols is not None:
pf.write('Columns: %s\n' % ncols)
for color in clrs:
r, g, b = color.getRGB()
n = 'Untitled'
s = '%d %d %d %s\n' % (r, g, b, n)
pf.write(s)
if do_close:
pf.close()
class GimpPalette(Storage):
name = 'gpl'
title = _("Gimp palette")
filters = ["*.gpl"]
can_save = True
can_load = True
@staticmethod
def get_options_widget(dialog, filename):
def on_columns_changed(n):
dialog.options = n
dialog.on_current_changed(filename)
ncols = None
pf = open(filename,'r')
l = pf.readline().strip()
if l != 'GIMP Palette':
pf.close()
return None
for line in pf:
line = line.strip()
lst = line.split()
if lst[0]=='Columns:':
ncols = int( lst[1] )
break
pf.close()
widget = QtGui.QWidget()
box = QtGui.QHBoxLayout()
label = QtGui.QLabel(_("Columns: "))
spinbox = QtGui.QSpinBox()
spinbox.setMinimum(2)
spinbox.setMaximum(100)
if ncols is None:
ncols = MAX_COLS
spinbox.setValue(ncols)
box.addWidget(label)
box.addWidget(spinbox)
spinbox.valueChanged.connect(on_columns_changed)
widget.setLayout(box)
return widget
def save(self, file_w=None):
if type(file_w) in [str,unicode]:
pf = open(file_w, 'w')
do_close = True
elif hasattr(file_w,'write'):
pf = file_w
do_close = False
else:
raise ValueError("Invalid argument type in GimpPalette.save: {}".format(type(file_w)))
pf.write('GIMP Palette\n')
if not hasattr(self.palette, 'name'):
if type(file_w) in [str, unicode]:
self.palette.name = basename(file_w)
else:
self.palette.name='Colors'
pf.write(u"Name: {}\n".format(self.palette.name).encode('utf-8'))
if hasattr(self.palette, 'ncols') and self.palette.ncols:
pf.write('Columns: %s\n' % self.palette.ncols)
pf.write(marker+'\n')
for key,value in self.palette.meta.items():
if key != "Name":
pf.write(u"# {}: {}\n".format(key, value).encode('utf-8'))
pf.write('#\n')
for row in self.palette.slots:
for slot in row:
if slot.mode == USER_DEFINED:
n = slot.name + ' #USER'
else:
n = slot.name
r, g, b = slot.color.getRGB()
s = '%d %d %d %s\n' % (r, g, b, n)
pf.write(s)
for key,value in slot.color.meta.items():
if key != "Name":
pf.write(u"# {}: {}\n".format(key, value).encode('utf-8'))
if do_close:
pf.close()
def load(self, mixer, file_r, force_ncols=None):
self.palette = Palette(mixer)
self.palette.ncols = None
if not file_r:
palette.filename = None
palette.name = 'Gimp'
return
elif hasattr(file_r,'read'):
pf = file_r
self.palette.filename = None
do_close = False
elif type(file_r) in [str,unicode]:
pf = open(file_r)
self.palette.filename = file_r
do_close = True
l = pf.readline().strip()
if l != 'GIMP Palette':
raise SyntaxError, "Invalid palette file!"
self.palette.name = " ".join(pf.readline().strip().split()[1:])
all_user = True
n_colors = 0
all_slots = []
reading_header = True
for line in pf:
line = line.strip()
if line==marker:
all_user = False
meta_match = metare.match(line)
if meta_match is not None:
key = meta_match.group(1)
value = meta_match.group(2)
if reading_header:
self.palette.meta[key] = value
else:
clr.meta[key] = value
continue
if line.startswith('#'):
continue
lst = line.split()
if lst[0]=='Columns:':
self.palette.ncols = int( lst[1] )
if len(lst) < 3:
continue
rs,gs,bs = lst[:3]
clr = Color(float(rs), float(gs), float(bs))
reading_header = False
#print(str(clr))
slot = Slot(clr)
n_colors += 1
if all_user or lst[-1]=='#USER':
slot.mode = USER_DEFINED
name = ' '.join(lst[3:-1])
else:
name = ' '.join(lst[3:])
slot.name = name
all_slots.append(slot)
if do_close:
pf.close()
if n_colors < DEFAULT_GROUP_SIZE:
self.palette.ncols = n_colors
if not self.palette.ncols:
if n_colors > MAX_COLS:
self.palette.ncols = MAX_COLS
else:
self.palette.ncols = n_colors
if force_ncols is not None:
self.palette.ncols = force_ncols
self.palette.setSlots(all_slots)
self.palette.meta["SourceFormat"] = "Gimp gpl" if all_user else "palette_editor gpl"
print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols ))
return self.palette

View File

@ -0,0 +1,201 @@
from os.path import join, basename, splitext
from math import sqrt, floor
from PyQt4 import QtGui
from color.colors import *
from color import spaces
from color import mixers
from palette.image import PaletteImage
from palette.palette import *
from palette.storage.storage import *
from palette.storage.cluster import *
from palette.storage.table import parse_color_table
from matching.transform import rho, get_center
LOAD_MORE = 1
LOAD_LESS_COMMON = 2
LOAD_LESS_FAREST = 3
LOAD_TABLE = 4
print("Ok")
class DialogOptions(object):
def __init__(self, method):
self.method = method
self.border_x = 10
self.border_y = 10
self.gap_x = 10
self.gap_y = 10
self.size_x = 5
self.size_y = 5
class Image(Storage):
name = 'image'
title = _("Raster image")
filters = ["*.jpg", "*.png", "*.gif"]
can_load = image_loading_supported
can_save = True
@staticmethod
def check(filename):
return True
@staticmethod
def get_options_widget(dialog, filename):
if use_sklearn:
return None
def dependencies():
if dialog.options is None or dialog.options.method != LOAD_TABLE:
table_w.setVisible(False)
else:
table_w.setVisible(True)
def on_method_changed(checked):
method = None
if dialog._more_button.isChecked():
method = LOAD_MORE
elif dialog._less_button.isChecked():
method = LOAD_LESS_COMMON
elif dialog._less_farest.isChecked():
method = LOAD_LESS_FAREST
elif dialog._table.isChecked():
method = LOAD_TABLE
dialog.options = DialogOptions(method)
if method == LOAD_TABLE:
dialog.options.border_x = dialog._border_x.value()
dialog.options.border_y = dialog._border_y.value()
dialog.options.gap_x = dialog._gap_x.value()
dialog.options.gap_y = dialog._gap_y.value()
dialog.options.size_x = dialog._size_x.value()
dialog.options.size_y = dialog._size_y.value()
dependencies()
dialog.on_current_changed(filename)
group_box = QtGui.QGroupBox(_("Loading method"))
dialog._more_button = more = QtGui.QRadioButton(_("Use 49 most used colors"))
dialog._less_button = less = QtGui.QRadioButton(_("Use 9 most used colors and mix them"))
dialog._less_farest = less_farest = QtGui.QRadioButton(_("Use 9 most different colors and mix them"))
dialog._table = table = QtGui.QRadioButton(_("Load table of colors"))
table_w = QtGui.QWidget(dialog)
table_form = QtGui.QFormLayout(table_w)
dialog._border_x = QtGui.QSpinBox(table_w)
dialog._border_x.valueChanged.connect(on_method_changed)
table_form.addRow(_("Border from right/left side, px"), dialog._border_x)
dialog._border_y = QtGui.QSpinBox(table_w)
dialog._border_y.valueChanged.connect(on_method_changed)
table_form.addRow(_("Border from top/bottom side, px"), dialog._border_y)
dialog._gap_x = QtGui.QSpinBox(table_w)
dialog._gap_x.valueChanged.connect(on_method_changed)
table_form.addRow(_("Width of gap between cells, px"), dialog._gap_x)
dialog._gap_y = QtGui.QSpinBox(table_w)
dialog._gap_y.valueChanged.connect(on_method_changed)
table_form.addRow(_("Height of gap between cells, px"), dialog._gap_y)
dialog._size_x = QtGui.QSpinBox(table_w)
dialog._size_x.setValue(5)
dialog._size_x.valueChanged.connect(on_method_changed)
table_form.addRow(_("Number of columns in the table"), dialog._size_x)
dialog._size_y = QtGui.QSpinBox(table_w)
dialog._size_y.setValue(5)
dialog._size_y.valueChanged.connect(on_method_changed)
table_form.addRow(_("Number of rows in the table"), dialog._size_y)
table_w.setLayout(table_form)
dependencies()
more.toggled.connect(on_method_changed)
less.toggled.connect(on_method_changed)
less_farest.toggled.connect(on_method_changed)
table.toggled.connect(on_method_changed)
if dialog.options is None or dialog.options.method == LOAD_MORE:
more.setChecked(True)
elif dialog.options.method == LOAD_LESS_COMMON:
less.setChecked(True)
elif dialog.options.method == LOAD_TABLE:
table.setChecked(True)
else:
less_farest.setChecked(True)
vbox = QtGui.QVBoxLayout()
vbox.addWidget(more)
vbox.addWidget(less)
vbox.addWidget(less_farest)
vbox.addWidget(table)
vbox.addWidget(table_w)
group_box.setLayout(vbox)
return group_box
def save(self, file_w):
w,h = self.palette.ncols * 48, self.palette.nrows * 48
image = PaletteImage( self.palette ).get(w,h)
print("Writing image: " + file_w)
res = image.save(file_w)
if not res:
image.save(file_w, format='PNG')
def load(self, mixer, file_r, options=None):
def _cmp(clr1,clr2):
h1,s1,v1 = clr1.getHSV()
h2,s2,v2 = clr2.getHSV()
x = cmp(h1,h2)
if x != 0:
return x
x = cmp(v1,v2)
if x != 0:
return x
return cmp(s1,s2)
def get_farest(space, colors, n=9):
points = [space.getCoords(c) for c in colors]
center = get_center(points)
srt = sorted(points, key = lambda c: -rho(center, c))
farest = srt[:n]
return [space.fromCoords(c) for c in farest]
if use_sklearn or options is None or options.method is None or options.method == LOAD_MORE:
colors = get_common_colors(file_r)
colors.sort(cmp=_cmp)
self.palette = create_palette(colors, mixer)
return self.palette
elif options.method == LOAD_TABLE:
self.palette = palette = Palette(mixer, nrows=options.size_y, ncols=options.size_x)
colors = parse_color_table(file_r, options)
for i in range(0, options.size_y):
for j in range(0, options.size_x):
palette.paint(i, j, colors[i][j])
return palette
else:
if options.method == LOAD_LESS_FAREST:
colors = get_common_colors(file_r)
colors = get_farest(spaces.RGB, colors)
else:
colors = get_common_colors(file_r, n_clusters=9)
self.palette = palette = Palette(mixer, nrows=7, ncols=7)
palette.paint(0, 0, colors[0])
palette.paint(0, 3, colors[1])
palette.paint(0, 6, colors[2])
palette.paint(3, 0, colors[3])
palette.paint(3, 3, colors[4])
palette.paint(3, 6, colors[5])
palette.paint(6, 0, colors[6])
palette.paint(6, 3, colors[7])
palette.paint(6, 6, colors[8])
palette.need_recalc_colors = True
palette.recalc()
name,ext = splitext(basename(file_r))
self.palette.meta["SourceFormat"] = ext
return palette

View File

@ -0,0 +1,178 @@
from os.path import join, basename
from PyQt4 import QtGui
from lxml import etree as ET
from zipfile import ZipFile, ZIP_DEFLATED
from color.colors import *
from color import mixers
from palette.storage.storage import *
MIMETYPE = "application/x-krita-palette"
DEFAULT_GROUP_NAME = "Default (root)"
class KplPalette(Storage):
name = 'kpl'
title = _("Krita 4.0+ palette format")
filters = ["*.kpl"]
can_load = True
can_save = True
@staticmethod
def check(filename):
try:
with ZipFile(filename, 'r') as zf:
mimetype = zf.read("mimetype")
return (mimetype == MIMETYPE)
except Exception as e:
print(e)
return False
@staticmethod
def get_group_names(filename):
result = [DEFAULT_GROUP_NAME]
with ZipFile(filename, 'r') as zf:
colorset_str = zf.read("colorset.xml")
colorset = ET.fromstring(colorset_str)
for xmlgrp in colorset.xpath("//Group"):
name = xmlgrp.attrib['name']
if name is not None:
result.append(name)
return result
@staticmethod
def get_options_widget(dialog, filename):
dialog.options = DEFAULT_GROUP_NAME
def on_group_changed(selector):
def handler():
dialog.options = selector.currentText()
dialog.on_current_changed(filename)
return handler
widget = QtGui.QWidget()
box = QtGui.QHBoxLayout()
label = QtGui.QLabel(_("Group: "))
selector = QtGui.QComboBox()
for group_name in KplPalette.get_group_names(filename):
selector.addItem(group_name)
selector.currentIndexChanged.connect(on_group_changed(selector))
selector.setCurrentIndex(0)
box.addWidget(label)
box.addWidget(selector)
widget.setLayout(box)
return widget
def load(self, mixer, file_r, group_name):
if group_name is None:
group_name = DEFAULT_GROUP_NAME
def find_group(xml):
if group_name == DEFAULT_GROUP_NAME:
return xml
for xmlgrp in xml.xpath("//Group"):
if xmlgrp.attrib['name'] == group_name:
return xmlgrp
return None
self.palette = Palette(mixer)
with ZipFile(file_r, 'r') as zf:
mimetype = zf.read("mimetype")
if mimetype != MIMETYPE:
raise Exception("This is not a valid Krita palette file")
colorset_str = zf.read("colorset.xml")
colorset = ET.fromstring(colorset_str)
self.palette.ncols = int( colorset.attrib['columns'] )
self.palette.name = colorset.attrib.get('name', "Untitled")
group = find_group(colorset)
if group is None:
print(u"Cannot find group by name {}".format(group_name).encode('utf-8'))
return None
else:
self.palette.name = self.palette.name + " - " + group.attrib.get('name', 'Untitled')
all_slots = []
n_colors = 0
for xmlclr in group.findall('ColorSetEntry'):
name = xmlclr.attrib['name']
if xmlclr.attrib['bitdepth'] != 'U8':
print("Skip color {}: unsupported bitdepth".format(name))
continue
rgb = xmlclr.find('RGB')
if rgb is None:
rgb = xmlclr.find('sRGB')
if rgb is None:
print("Skip color {}: no RGB representation".format(name))
continue
r = float(rgb.attrib['r'].replace(',', '.'))
g = float(rgb.attrib['g'].replace(',', '.'))
b = float(rgb.attrib['b'].replace(',', '.'))
clr = Color()
clr.setRGB1((r,g,b))
clr.name = name
slot = Slot(clr)
slot.mode = USER_DEFINED
all_slots.append(slot)
n_colors += 1
if n_colors < DEFAULT_GROUP_SIZE:
self.palette.ncols = n_colors
if not self.palette.ncols:
if n_colors > MAX_COLS:
self.palette.ncols = MAX_COLS
else:
self.palette.ncols = n_colors
#print("Loaded colors: {}".format(len(all_slots)))
self.palette.setSlots(all_slots)
self.palette.meta["SourceFormat"] = "KRITA KPL"
print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols ))
return self.palette
def save(self, file_w=None):
with ZipFile(file_w, 'w', ZIP_DEFLATED) as zf:
zf.writestr("mimetype", MIMETYPE)
xml = ET.Element("Colorset")
xml.attrib['version'] = '1.0'
xml.attrib['columns'] = str(self.palette.ncols)
xml.attrib['name'] = self.palette.name
xml.attrib['comment'] = self.palette.meta.get("Comment", "Generated by Palette Editor")
for i,row in enumerate(self.palette.slots):
for j,slot in enumerate(row):
color = slot.color
name = color.name
default_name = "Swatch-{}-{}".format(i,j)
if not name:
name = default_name
elem = ET.SubElement(xml, "ColorSetEntry")
elem.attrib['spot'] = color.meta.get("Spot", "false")
elem.attrib['id'] = default_name
elem.attrib['name'] = name
elem.attrib['bitdepth'] = 'U8'
r,g,b = color.getRGB1()
srgb = ET.SubElement(elem, "sRGB")
srgb.attrib['r'] = str(r)
srgb.attrib['g'] = str(g)
srgb.attrib['b'] = str(b)
tree = ET.ElementTree(xml)
tree_str = ET.tostring(tree, encoding='utf-8', pretty_print=True, xml_declaration=False)
zf.writestr("colorset.xml", tree_str)

View File

@ -0,0 +1,76 @@
from math import floor
from os.path import join, basename
from lxml import etree as ET
from color.colors import *
from color import mixers
from palette.storage.storage import *
cmyk_factor = float(1.0/255.0)
def fromHex_CMYK(s):
t = s[1:]
cs,ms,ys,ks = t[0:2], t[2:4], t[4:6], t[6:8]
c,m,y,k = int(cs,16), int(ms,16), int(ys,16), int(ks,16)
c,m,y,k = [float(x)*cmyk_factor for x in [c,m,y,k]]
result = Color()
result.setCMYK((c,m,y,k))
return result
class Scribus(Storage):
name = 'scribus'
title = _("Scribus color swatches")
filters = ["*.xml"]
can_load = True
can_save = True
@staticmethod
def check(filename):
return ET.parse(filename).getroot().tag == 'SCRIBUSCOLORS'
def load(self, mixer, file_r, options=None):
xml = ET.parse(file_r).getroot()
colors = []
name = xml.attrib.get("Name", "Untitled")
for elem in xml.findall("COLOR"):
if "RGB" in elem.attrib:
color = fromHex(elem.attrib["RGB"])
colors.append(color)
elif "CMYK" in elem.attrib:
color = fromHex_CMYK(elem.attrib["CMYK"])
colors.append(color)
else:
continue
if "NAME" in elem.attrib:
color.meta["Name"] = elem.attrib["NAME"]
if "Spot" in elem.attrib:
color.meta["Spot"] = elem.attrib["Spot"]
if "Register" in elem.attrib:
color.meta["Register"] = elem.attrib["Register"]
self.palette = create_palette(colors)
self.palette.name = name
return self.palette
def save(self, file_w):
name = self.palette.name
if not name:
name = "Palette"
xml = ET.Element("SCRIBUSCOLORS", NAME=name)
for i,row in enumerate(self.palette.getColors()):
for j,color in enumerate(row):
name = color.name
if not name:
name = "Swatch-{}-{}".format(i,j)
elem = ET.SubElement(xml, "COLOR", NAME=name, RGB=color.hex())
if "Spot" in color.meta:
elem.attrib["Spot"] = color.meta["Spot"]
if "Register" in color.meta:
elem.attrib["Register"] = color.meta["Register"]
ET.ElementTree(xml).write(file_w, encoding="utf-8", pretty_print=True, xml_declaration=True)

View File

@ -0,0 +1,62 @@
from os.path import join, basename
from math import sqrt, floor
from color.colors import *
from color import mixers
from palette.palette import *
class Storage(object):
name = None
title = None
filters = []
can_load = False
can_save = False
def __init__(self, palette=None):
self.palette = palette
@classmethod
def get_filter(cls):
return u"{} ({})".format(cls.title, u" ".join(cls.filters))
@staticmethod
def check(filename):
return True
@staticmethod
def get_options_widget(dialog, filename):
return None
def load(self, mixer, file_r, *args, **kwargs):
raise NotImplemented
def save(self, file_w):
raise NotImplemented
def create_palette(colors, mixer=None, ncols=None):
"""Create Palette from list of Colors."""
if mixer is None:
mixer = mixers.MixerRGB
palette = Palette(mixer)
palette.ncols = ncols
all_slots = []
for clr in colors:
slot = Slot(clr, user_defined=True)
all_slots.append(slot)
n_colors = len(all_slots)
if palette.ncols is None:
if n_colors > MAX_COLS:
palette.ncols = max( int( floor(sqrt(n_colors)) ), 1)
else:
palette.ncols = n_colors
palette.setSlots(all_slots)
return palette

View File

@ -0,0 +1,179 @@
from os.path import join, basename
from PyQt4 import QtGui
from lxml import etree as ET
from color.colors import *
from color import mixers
from palette.storage.storage import *
class XmlPalette(Storage):
name = 'xml'
title = _("CREATE palette format")
filters = ["*.xml"]
can_load = True
can_save = True
@staticmethod
def check(filename):
return ET.parse(filename).getroot().tag == 'colors'
@staticmethod
def get_options_widget(dialog, filename):
def on_group_changed(selector):
def handler():
dialog.options = selector.currentText()
dialog.on_current_changed(filename)
return handler
widget = QtGui.QWidget()
box = QtGui.QHBoxLayout()
label = QtGui.QLabel(_("Group: "))
selector = QtGui.QComboBox()
xml = ET.parse(filename)
for xmlgrp in xml.xpath("//group"):
xml_label = xmlgrp.find('label')
if xml_label is not None:
selector.addItem(xml_label.text)
selector.currentIndexChanged.connect(on_group_changed(selector))
selector.setCurrentIndex(0)
box.addWidget(label)
box.addWidget(selector)
widget.setLayout(box)
return widget
@staticmethod
def get_group_names(filename):
result = []
xml = ET.parse(filename)
for xmlgrp in xml.xpath("//group"):
xml_label = xmlgrp.find('label')
if xml_label is not None:
result.append(xml_label.text)
return result
def save(self, file_w=None):
xml = ET.Element("colors")
root_group = ET.SubElement(xml, "group")
group = ET.SubElement(root_group, "group")
label = ET.SubElement(group, "label")
label.text = self.palette.name
layout = ET.SubElement(group, "layout", columns=str(self.palette.ncols), expanded="True")
for key,value in self.palette.meta.items():
if key != "Name":
meta = ET.SubElement(group, "meta", name=key)
meta.text = value
for i,row in enumerate(self.palette.slots):
for j,slot in enumerate(row):
color = slot.color
name = color.name
if not name:
name = "Swatch-{}-{}".format(i,j)
elem = ET.SubElement(group, "color")
label = ET.SubElement(elem, "label")
label.text = name
if slot.mode == USER_DEFINED:
meta = ET.SubElement(elem, "meta", name="user_chosen")
meta.text = "True"
for key,value in color.meta.items():
if key != "Name":
meta = ET.SubElement(elem, "meta", name=key)
meta.text = value
rgb = ET.SubElement(elem, "sRGB")
r,g,b = color.getRGB1()
rgb.attrib["r"] = str(r)
rgb.attrib["g"] = str(g)
rgb.attrib["b"] = str(b)
ET.ElementTree(xml).write(file_w, encoding="utf-8", pretty_print=True, xml_declaration=True)
def load(self, mixer, file_r, group_name):
def get_label(grp):
xml_label = grp.find('label')
if xml_label is None:
return None
return xml_label.text
def find_group(xml):
#print("Searching {}".format(group_name))
grp = None
for xmlgrp in xml.xpath("//group"):
label = get_label(xmlgrp)
if label is None:
continue
#print("Found: {}".format(label))
if group_name is None or label == group_name:
grp = xmlgrp
break
return grp
self.palette = Palette(mixer)
self.palette.ncols = None
xml = ET.parse(file_r)
grp = find_group(xml)
if grp is None:
print(u"Cannot find group by name {}".format(group_name).encode('utf-8'))
return None
self.palette.name = get_label(grp)
layout = grp.find('layout')
if layout is not None:
self.palette.ncols = int( layout.attrib['columns'] )
metas = grp.findall('meta')
if metas is not None:
for meta in metas:
key = meta.attrib['name']
value = meta.text
if key != 'Name':
self.palette.meta[key] = value
all_slots = []
n_colors = 0
for xmlclr in grp.findall('color'):
sRGB = xmlclr.find('sRGB')
if sRGB is None:
continue
attrs = sRGB.attrib
r = float(attrs['r'].replace(',','.'))
g = float(attrs['g'].replace(',','.'))
b = float(attrs['b'].replace(',','.'))
clr = Color()
clr.setRGB1((r,g,b))
slot = Slot(clr)
metas = xmlclr.findall('meta')
if metas is not None:
for meta in metas:
key = meta.attrib['name']
value = meta.text
if key == 'user_chosen' and value == 'True':
slot.mode = USER_DEFINED
else:
clr.meta[key] = value
label = xmlclr.find('label')
if label is not None:
clr.name = label.text
all_slots.append(slot)
n_colors += 1
if n_colors < DEFAULT_GROUP_SIZE:
self.palette.ncols = n_colors
if not self.palette.ncols:
if n_colors > MAX_COLS:
self.palette.ncols = MAX_COLS
else:
self.palette.ncols = n_colors
#print("Loaded colors: {}".format(len(all_slots)))
self.palette.setSlots(all_slots)
self.palette.meta["SourceFormat"] = "CREATE XML"
print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols ))
return self.palette

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python
# coding=utf-8
#
# Copyright (C) 2009-2018 Ilya Portnov <portnov84@rambler.ru>
# (original 'palette-editor' tool, version 0.0.7)
# 2020 Maren Hachmann (extension-ification)
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# Helper functions
def circle_hue(i, n, h, max=1.0):
h += i * max / n
if h > max:
h -= max
return h
def seq(start, stop, step=1):
n = int(round((stop - start)/step))
if n > 1:
return([start + step*i for i in range(n+1)])
else:
return([])
def variate(x, step=1.0, dmin=1.0, dmax=None):
if dmax is None:
dmax = dmin
return seq(x-dmin, x+dmax, step)
def clip(x, min=0.0, max=1.0):
if x < min:
#print("{:.2f} clipped up to {:.2f}".format(x, min))
return min
if x > max:
#print("{:.2f} clipped down to {:.2f}".format(x, max))
return max
return x

View File

@ -0,0 +1,21 @@
[
{
"name": "Color Harmony",
"id": "fablabchemnitz.de.color_harmony",
"path": "color_harmony",
"dependent_extensions": null,
"original_name": "Color harmony",
"original_id": "de.vektorrascheln.color_harmony",
"license": "GNU GPL v2",
"license_url": "https://gitlab.com/moini_ink/color-harmony/-/blob/master/LICENSE",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/color_harmony",
"fork_url": "https://gitlab.com/moini_ink/color-harmony",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Color+Harmony",
"inkscape_gallery_url": null,
"main_authors": [
"gitlab.com/moini_ink",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Destructive Clip</name>
<id>fablabchemnitz.de.destructive_clip</id>
<effect>
<object-type>path</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Paths - Cut/Intersect/Purge" />
</submenu>
</effects-menu>
<menu-tip>"Destructively" clip selected paths using the topmost as clipping path</menu-tip>
</effect>
<script>
<command location="inx" interpreter="python">destructive_clip.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,220 @@
#!/usr/bin/env python3
'''
---DESTRUCTIVE Clip---
An Inkscape Extension which works like Object|Clip|Set except that the paths clipped are actually *modified*
Thus the clipping is included when exported, for example as a DXF file.
Select two or more *paths* then choose Extensions|Modify path|Destructive clip. The topmost path will be used to clip the others.
Notes:-
* Curves in paths are not supported (use Flatten Beziers).
* Non-path objects in the selection will be ignored. Use Object|Ungroup.
* Paths entirely outside the clipping path will remain untouched (rather than modifying them to an empty path)
* Complex paths may take a while (there seems to be no way too show progress)
* Yes, using MBR's to do gross clipping might make it faster
* No, Python is not my first language (C/C++ is)
Mark Wilson Feb 2016
----
Edits by Windell H. Oskay, www.evilmadscientit.com, August 2020
Update calls to Inkscape 1.0 extension API to avoid deprecation warnings
Minimal standardization of python whitespace
Handle some errors more gracefully
'''
import inkex
import sys
from inkex.paths import Path
class DestructiveClip(inkex.EffectExtension):
def __init__(self):
self.tolerance = 0.0001 # arbitrary fudge factor
inkex.Effect.__init__(self)
self.error_messages = []
self.curve_error = 'Unable to parse path.\nConsider removing curves with Extensions > Modify Path > Flatten Beziers...'
def approxEqual(self, a, b):
# compare with tiny tolerance
return abs(a-b) <= self.tolerance
def midPoint(self, line):
# midPoint of line
return [(line[0][0] + line[1][0])/2, (line[0][1] + line[1][1])/2]
def maxX(self, lineSegments):
# return max X coord of lineSegments
maxx = 0.0
for line in lineSegments:
maxx = max(maxx, line[0][0])
maxx = max(maxx, line[1][0])
return maxx
def simplepathToLineSegments(self, path):
# takes a simplepath and converts to line *segments*, for simplicity.
# Thus [MoveTo P0, LineTo P1, LineTo P2] becomes [[P0-P1],[P1,P2]]
# only handles, Move, Line and Close.
# The simplepath library has already simplified things, normalized relative commands, etc
lineSegments = first = prev = this = []
errors = set([]) # Similar errors will be stored only once
for cmd in path:
this = cmd[1]
if cmd[0] == 'M': # moveto
if first == []:
first = this
elif cmd[0] == 'L': # lineto
lineSegments.append([prev, this])
elif cmd[0] == 'Z': # close
lineSegments.append([prev, first])
first = []
elif cmd[0] == 'C':
# https://developer.mozilla.org/en/docs/Web/SVG/Tutorial/Paths
lineSegments.append([prev, [this[4], this[5]]])
errors.add("Curve node detected (svg type C), this node will be handled as a regular node")
else:
errors.add("Invalid node type detected: {}. This script only handle type M, L, Z".format(cmd[0]))
prev = this
return (lineSegments, errors)
def linesgmentsToSimplePath(self, lineSegments):
# reverses simplepathToLines - converts line segments to Move/Line-to's
path = []
end = None
for line in lineSegments:
start = line[0]
if end is None:
path.append(['M', start]) # start with a move
elif not (self.approxEqual(end[0], start[0]) and self.approxEqual(end[1], start[1])):
path.append(['M', start]) # only move if previous end not within tolerance of this start
end = line[1]
path.append(['L', end])
return path
def lineIntersection(self, L1From, L1To, L2From, L2To):
# returns as [x, y] the intersection of the line L1From-L1To and L2From-L2To, or None
# http://stackoverflow.com/questions/563198/how-do-you-detect-where-two-line-segments-intersect
try:
dL1 = [L1To[0] - L1From[0], L1To[1] - L1From[1]]
dL2 = [L2To[0] - L2From[0], L2To[1] - L2From[1]]
except IndexError:
inkex.errormsg(self.curve_error)
sys.exit()
denominator = -dL2[0]*dL1[1] + dL1[0]*dL2[1]
if not self.approxEqual(denominator, 0.0):
s = (-dL1[1]*(L1From[0] - L2From[0]) + dL1[0]*(L1From[1] - L2From[1]))/denominator
t = (+dL2[0]*(L1From[1] - L2From[1]) - dL2[1]*(L1From[0] - L2From[0]))/denominator
if s >= 0.0 and s <= 1.0 and t >= 0.0 and t <= 1.0:
return [L1From[0] + (t * dL1[0]), L1From[1] + (t * dL1[1])]
else:
return None
def insideRegion(self, point, lineSegments, lineSegmentsMaxX):
# returns true if point is inside the region defined by lineSegments. lineSegmentsMaxX is the maximum X extent
ray = [point, [lineSegmentsMaxX*2.0, point[1]]] # hz line to right of point, extending well outside MBR
crossings = 0
for line in lineSegments:
if not self.lineIntersection(line[0], line[1], ray[0], ray[1]) is None:
crossings += 1
return (crossings % 2) == 1 # odd number of crossings means inside
def cullSegmentedLine(self, segmentedLine, lineSegments, lineSegmentsMaxX):
# returns just the segments in segmentedLine which are inside lineSegments
culled = []
for segment in segmentedLine:
if self.insideRegion(self.midPoint(segment), lineSegments, lineSegmentsMaxX):
culled.append(segment)
return culled
def clipLine(self, line, lineSegments):
# returns line split where-ever lines in lineSegments cross it
linesWrite = [line]
for segment in lineSegments:
linesRead = linesWrite
linesWrite = []
for line in linesRead:
intersect = self.lineIntersection(line[0], line[1], segment[0], segment[1])
if intersect is None:
linesWrite.append(line)
else: # split
linesWrite.append([line[0], intersect])
linesWrite.append([intersect, line[1]])
return linesWrite
def clipLineSegments(self, lineSegmentsToClip, clippingLineSegments):
# return the lines in lineSegmentsToClip clipped by the lines in clippingLineSegments
clippedLines = []
for lineToClip in lineSegmentsToClip:
clippedLines.extend(self.cullSegmentedLine(self.clipLine(lineToClip, clippingLineSegments), clippingLineSegments, self.maxX(clippingLineSegments)))
return clippedLines
#you can also run the extension Modify Path > To Absolute Coordinates to convert VH to L
def fixVHbehaviour(self, elem):
raw = Path(elem.get("d")).to_arrays()
subpaths, prev = [], 0
for i in range(len(raw)): # Breaks compound paths into simple paths
if raw[i][0] == 'M' and i != 0:
subpaths.append(raw[prev:i])
prev = i
subpaths.append(raw[prev:])
seg = []
for simpath in subpaths:
if simpath[-1][0] == 'Z':
simpath[-1][0] = 'L'
if simpath[-2][0] == 'L': simpath[-1][1] = simpath[0][1]
else: simpath.pop()
for i in range(len(simpath)):
if simpath[i][0] == 'V': # vertical and horizontal lines only have one point in args, but 2 are required
#inkex.utils.debug(simpath[i][0])
simpath[i][0]='L' #overwrite V with regular L command
add=simpath[i-1][1][0] #read the X value from previous segment
simpath[i][1].append(simpath[i][1][0]) #add the second (missing) argument by taking argument from previous segment
simpath[i][1][0]=add #replace with recent X after Y was appended
if simpath[i][0] == 'H': # vertical and horizontal lines only have one point in args, but 2 are required
#inkex.utils.debug(simpath[i][0])
simpath[i][0]='L' #overwrite H with regular L command
simpath[i][1].append(simpath[i-1][1][1]) #add the second (missing) argument by taking argument from previous segment
#inkex.utils.debug(simpath[i])
seg.append(simpath[i])
elem.set("d", Path(seg))
return seg
def effect(self):
clippingLineSegments = None
pathTag = inkex.addNS('path', 'svg')
groupTag = inkex.addNS('g', 'svg')
self.error_messages = []
for id in self.options.ids: # the selection, top-down
node = self.svg.selected[id]
if node.tag == pathTag:
path = self.fixVHbehaviour(node)
if clippingLineSegments is None: # first path is the clipper
#(clippingLineSegments, errors) = self.simplepathToLineSegments(node.path.to_arrays())
(clippingLineSegments, errors) = self.simplepathToLineSegments(path)
self.error_messages.extend(['{}: {}'.format(id, err) for err in errors])
else:
# do all the work!
#segmentsToClip, errors = self.simplepathToLineSegments(node.path.to_arrays())
segmentsToClip, errors = self.simplepathToLineSegments(path)
self.error_messages.extend(['{}: {}'.format(id, err) for err in errors])
clippedSegments = self.clipLineSegments(segmentsToClip, clippingLineSegments)
if len(clippedSegments) != 0:
path = str(inkex.Path(self.linesgmentsToSimplePath(clippedSegments)))
node.set('d', path)
else:
# don't put back an empty path(?) could perhaps put move, move?
inkex.errormsg('Object {} clipped to nothing, will not be updated.'.format(node.get('id')))
elif node.tag == groupTag: # we don't look inside groups for paths
inkex.errormsg('Group object {} will be ignored. Please ungroup before running the script.'.format(id))
else: # something else
inkex.errormsg('Object {} is not of type path ({}), and will be ignored. Current type "{}".'.format(id, pathTag, node.tag))
for error in self.error_messages:
inkex.errormsg(error)
if __name__ == '__main__':
DestructiveClip().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Destructive Clip",
"id": "fablabchemnitz.de.destructive_clip",
"path": "destructive_clip",
"dependent_extensions": null,
"original_name": "Destructive Clip",
"original_id": "com.funnypolynomial.inkscape.extension.destructiveclip",
"license": "MIT License",
"license_url": "https://github.com/funnypolynomial/DestructiveClip/blob/master/LICENSE",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/destructive_clip",
"fork_url": "https://github.com/funnypolynomial/DestructiveClip",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Destructive+Clip",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/funnypolynomial",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Dimensioning (Replaced by LPE)</name>
<id>fablabchemnitz.de.dimensioning</id>
<param name="tab" type="notebook">
<page name="general" gui-text="General Settings">
<param name="orientation" type="optiongroup" appearance="combo" gui-text="Orientation">
<option value="horizontal">horizontal</option>
<option value="vertical">vertical</option>
<option value="parallel">parallel</option>
</param>
<param name="arrow_orientation" type="optiongroup" appearance="combo" gui-text="Orientation of the arrows">
<option value="auto">auto</option>
<option value="inside">inside</option>
<option value="outside">outside</option>
</param>
<param name="line_scale" type="float" gui-text="Line scale factor:" min="0.1" max="10">1</param>
<param name="overlap" type="float" gui-text="Helpline overlap:" min="0" max="30">5</param>
<param name="distance" type="float" gui-text="Helpline distance:" min="0" max="50">0</param>
<param name="position" type="float" gui-text="Position:" min="-1000" max="1000">100</param>
<param name="flip" type="bool" gui-text="Flip side:">false</param>
</page>
<page name="annotation" gui-text="Annotation">
<param name="scale_factor" type="float" gui-text="Scalefactor (1:#):" min="0" max="10000">1</param>
<param name="unit" type="optiongroup" appearance="combo" gui-text="Units">
<option value="px">px</option>
<option value="pt">pt</option>
<option value="mm">mm</option>
<option value="cm">cm</option>
</param>
<param name="digit" type="int" min="-3" max="5" gui-text="Precision">0</param>
<!--
<param name="digit" type="optiongroup" appearance="combo" gui-text="Precision">
<option value="0">no digit - 11</option>
<option value="-1">rounded one digit - 10</option>
<option value="1">one digit - 11.1</option>
<option value="2">two digits - 11.11</option>
<option value="3">three digits - 11.111</option>
</param> -->
<param name="rotate" type="bool" gui-text="Rotate Annotation">true</param>
</page>
<page name="help" gui-text="Help">
<label>This tool draws beautiful DIN-Style dimensioning arrows.</label>
<label>Draw a path. The dimensioning will go from the start point to the end point of the path.</label>
</page>
</param>
<effect>
<object-type>path</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Dimensioning/Measuring"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">dimensioning.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,283 @@
#!/usr/bin/env python3
'''
Tool for drawing beautiful DIN-conform dimensioning arrows
(c) 2012 by Johannes B. Rutzmoser, johannes.rutzmoser (at) googlemail (dot) com
Please contact me, if you know a way how the extension module accepts mouse input; this would help to improve the tool
Add this file and the dimensioning.inx file into the following folder to get the feature run:
UNIX:
$HOME/.config/inkscape/extensions/
Mac OS X (when using the binary):
/Applications/Inkscape.app/Contents/Resources/extensions/
or
/Applications/Inkscape.app/Contents/Resources/share/inkscape/extensions
WINDOWS (Filepath may differ, depending where the program was installed):
C:\Program Files\Inkscape\share\extensions
License:
GNU GENERAL PUBLIC LICENSE
'''
import inkex
import numpy as np
import gettext
_ = gettext.gettext
from lxml import etree
from inkex import paths
def norm(a):
return a/np.sqrt(np.dot(a, a))
def rotate(tangentvec, point):
if tangentvec[0] == 0:
angle = - np.pi/2
else:
angle = np.arctan(tangentvec[1]/tangentvec[0])
return 'rotate(' + str(angle/np.pi*180) + ',' + str(point[0]) + ',' + str(point[1]) + ')'
class Dimensioning(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument("--orientation", default='horizontal', help="The type of orientation of the dimensioning (horizontal, vertical or parallel)")
pars.add_argument("--arrow_orientation", default='auto', help="The type of orientation of the arrows")
pars.add_argument("--line_scale", type=float, default=1.0, help="Scale factor for the line thickness")
pars.add_argument("--overlap", type=float, default=1.0, help="Overlap of the helpline over the dimensioning line")
pars.add_argument("--distance", type=float, default=1.0, help="Distance of the helpline to the object")
pars.add_argument("--position", type=float, default=1.0, help="position of the dimensioning line")
pars.add_argument("--flip", type=inkex.Boolean, default=False, help="flip side")
pars.add_argument("--scale_factor", type=float, default=1.0, help="scale factor for the dimensioning text")
pars.add_argument("--unit", default='px', help="The unit that should be used for the dimensioning")
pars.add_argument("--rotate", type=inkex.Boolean, default=True, help="Rotate the annotation?")
pars.add_argument("--digit", type=int, default=0, help="number of digits after the point")
pars.add_argument("--tab", default="sampling", help="The selected UI-tab when OK was pressed")
def create_linestyles(self):
'''
Create the line styles for the drawings.
'''
self.helpline_style = {
'stroke' : '#000000',
'stroke-width' : '{}px'.format(0.5*self.options.line_scale),
'fill' : 'none'
}
self.dimline_style = {
'stroke' : '#000000',
'stroke-width' : '{}px'.format(0.75*self.options.line_scale),
'fill' : 'none',
'marker-start' : 'url(#ArrowDIN-start)',
'marker-end' : 'url(#ArrowDIN-end)'
}
self.text_style = {
'font-size' : '{}px'.format(12*self.options.line_scale),
'font-family' : 'Sans',
'font-style' : 'normal',
'text-anchor' : 'middle'
}
self.helpline_attribs = {'style' : str(inkex.Style(self.helpline_style)),
inkex.addNS('label', 'inkscape') : 'helpline',
'd' : 'm 0,0 100,0'
}
self.text_attribs = {'style' : str(inkex.Style(self.text_style)),
'x' : '100',
'y' : '100'
}
self.dimline_attribs = {'style' : str(inkex.Style(self.dimline_style)),
inkex.addNS('label','inkscape') : 'dimline',
'd' : 'm 0,0 200,0'
}
def effect(self):
# will be executed when feature is activated
self.create_linestyles()
self.makeGroup()
self.getPoints()
self.calcab()
self.drawHelpline()
self.drawDimension()
self.drawText()
def makeMarkerstyle(self, name, rotate):
defs = self.svg.getElement('/svg:svg//svg:defs')
if defs == None:
defs = etree.SubElement(self.document.getroot(),inkex.addNS('defs','svg'))
marker = etree.SubElement(defs ,inkex.addNS('marker','svg'))
marker.set('id', name)
marker.set('orient', 'auto')
marker.set('refX', '0.0')
marker.set('refY', '0.0')
marker.set('style', 'overflow:visible')
marker.set(inkex.addNS('stockid','inkscape'), name)
arrow = etree.Element("path")
# messy but works; definition of arrows in beautiful DIN-shapes:
if name.startswith('ArrowDIN-'):
if rotate:
arrow.set('d', 'M 8,0 -8,2.11 -8,-2.11 z')
else:
arrow.set('d', 'M -8,0 8,-2.11 8,2.11 z')
if name.startswith('ArrowDINout-'):
if rotate:
arrow.set('d', 'M 0,0 16,2.11 16,0.5 26,0.5 26,-0.5 16,-0.5 16,-2.11 z')
else:
arrow.set('d', 'M 0,0 -16,2.11 -16,0.5 -26,0.5 -26,-0.5 -16,-0.5 -16,-2.11 z')
arrow.set('style', 'fill:#000000;stroke:none')
marker.append(arrow)
def makeGroup(self):
'''puts everything of the dimensioning in a group'''
layer = self.svg.get_current_layer()
# Group in which the object should be put into
grp_name = 'dimensioning'
grp_attributes = {inkex.addNS('label', 'inkscape') : grp_name}
self.grp = etree.SubElement(layer, 'g', grp_attributes)
def getPoints(self):
self.p1 = np.array([0.,100.])
self.p1 = np.array([100.,100.])
# Get variables of a selected object
for id, node in self.svg.selected.items():
# if it is a path:
if node.tag == inkex.addNS('path', 'svg'):
d = node.get('d')
p = paths.CubicSuperPath(d)
# p has all nodes with the anchor points in a list;
# the rule is [anchorpoint, node, anchorpoint]
# the points are lists with x and y coordinate
self.p1 = np.array(p[0][0][1])
self.p2 = np.array(p[0][-1][1])
def calcab(self):
# get p1,p2 ordered for correct dimension direction
# determine quadrant
if self.p1[0] <= self.p2[0]:
if self.p1[1] <= self.p2[1]:
quad = 1 # p1 is left,up of p2
else: quad = 2 # p1 is left,down of p2
elif self.p1[1] <= self.p2[1]:
quad = 3 # p1 is right,up of p2
else: quad = 4 # p1 is right,down of p2
swap = False if quad ==1 else True
minp = self.p2 if swap else self.p1
maxp = self.p1 if swap else self.p2
# distance between points
delta = maxp - minp
# rotation matrix
rotateMat = np.array([[0,-1],[1,0]])
# compute the unit vectors e1 and e2 along the cartesian coordinates of the dimension
if self.options.orientation == 'horizontal':
if quad == 3: self.e1 = np.array([1.0, 0.0])
else: self.e1 = np.array([-1.0, 0.0])
if self.options.orientation == 'vertical':
if quad == 2:
self.e1 = np.array([0.0, -1.0])
else: self.e1 = np.array([0.0, 1.0])
if self.options.orientation == 'parallel':
self.e1 = norm(delta)
#if quad==2 or quad==3: self.e1 *= -1
self.e2 = np.dot(rotateMat, self.e1)
if self.options.flip:
self.e2 *= -1.
# compute the points a and b, where the dimension line arrow spikes start and end
dist = self.options.position*self.e2
if self.options.flip:
outpt = maxp
delta *= -1
if swap:
self.a = outpt + dist
self.b = self.a + self.e1*np.dot(self.e1,delta)
else:
self.b = outpt + dist
self.a = self.b + self.e1*np.dot(self.e1,delta)
else:
outpt = minp
if swap:
self.b = outpt + dist
self.a = self.b + self.e1*np.dot(self.e1,delta)
else:
self.a = outpt + dist
self.b = self.a + self.e1*np.dot(self.e1,delta)
def drawHelpline(self):
# manipulate the start- and endpoints with distance and overlap
h1_start = self.p1 + norm(self.a - self.p1)*self.options.distance
h1_end = self.a + norm(self.a - self.p1)*self.options.overlap
h2_start = self.p2 + norm(self.b - self.p2)*self.options.distance
h2_end = self.b + norm(self.b - self.p2)*self.options.overlap
# print the lines
hline1 = etree.SubElement(self.grp, inkex.addNS('path', 'svg'), self.helpline_attribs)
hline1.set('d', 'M %f,%f %f,%f' % (h1_start[0], h1_start[1],h1_end[0],h1_end[1],))
hline2 = etree.SubElement(self.grp, inkex.addNS('path', 'svg'), self.helpline_attribs)
hline2.set('d', 'M %f,%f %f,%f' % (h2_start[0], h2_start[1],h2_end[0],h2_end[1],))
def setMarker(self, option):
if option=='inside':
# inside
self.arrowlen = 6.0 * self.options.line_scale
self.dimline_style['marker-start'] = 'url(#ArrowDIN-start)'
self.dimline_style['marker-end'] = 'url(#ArrowDIN-end)'
self.makeMarkerstyle('ArrowDIN-start', False)
self.makeMarkerstyle('ArrowDIN-end', True)
else:
# outside
self.arrowlen = 0
self.dimline_style['marker-start'] = 'url(#ArrowDINout-start)'
self.dimline_style['marker-end'] = 'url(#ArrowDINout-end)'
self.makeMarkerstyle('ArrowDINout-start', False)
self.makeMarkerstyle('ArrowDINout-end', True)
self.dimline_attribs['style'] = str(inkex.Style(self.dimline_style))
def drawDimension(self):
# critical length, when inside snaps to outside
critical_length = 35 * self.options.line_scale
if self.options.arrow_orientation == 'auto':
if np.abs(np.dot(self.e1, self.b - self.a)) > critical_length:
self.setMarker('inside')
else:
self.setMarker('outside')
else:
self.setMarker(self.options.arrow_orientation)
# start- and endpoint of the dimension line
dim_start = self.a + self.arrowlen*norm(self.b - self.a)
dim_end = self.b - self.arrowlen*norm(self.b - self.a)
# print
dimline = etree.SubElement(self.grp, inkex.addNS('path', 'svg'), self.dimline_attribs)
dimline.set('d', 'M %f,%f %f,%f' % (dim_start[0], dim_start[1], dim_end[0], dim_end[1]))
def drawText(self):
# distance of text to the dimension line
self.textdistance = 5.0 * self.options.line_scale
if self.e2[1] > 0:
textpoint = (self.a + self.b)/2 - self.e2*self.textdistance
elif self.e2[1] == 0:
textpoint = (self.a + self.b)/2 - np.array([1,0])*self.textdistance
else:
textpoint = (self.a + self.b)/2 + self.e2*self.textdistance
value = np.abs(np.dot(self.e1, self.b - self.a)) / (self.svg.unittouu(str(self.options.scale_factor)+self.options.unit))
string_value = str(round(value, self.options.digit))
# chop off last characters if digit is zero or negative
if self.options.digit <=0:
string_value = string_value[:-2]
text = etree.SubElement(self.grp, inkex.addNS('text', 'svg'), self.text_attribs)
# The alternative for framing with dollars, when LATEX Math export is seeked
# text.text = '$' + string_value + '$'
text.text = string_value
text.set('x', str(textpoint[0]))
text.set('y', str(textpoint[1]))
if self.options.rotate:
text.set('transform', rotate(self.e1, textpoint))
if __name__ == '__main__':
Dimensioning().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Dimensioning (Replaced by LPE)",
"id": "fablabchemnitz.de.dimensioning",
"path": "dimensioning",
"dependent_extensions": null,
"original_name": "Dimensioning",
"original_id": "dimensioning",
"license": "GNU GPL v3",
"license_url": "https://github.com/Rutzmoser/inkscape_dimensioning/blob/master/LICENSE",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/dimensioning",
"fork_url": "https://github.com/Rutzmoser/inkscape_dimensioning",
"documentation_url": "https://stadtfabrikanten.org/pages/viewpage.action?pageId=55018389",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/Rutzmoser",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Duplicate + Reverse + Join</name>
<id>fablabchemnitz.de.duplicate_reverse_join</id>
<effect>
<object-type>path</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Paths - Join/Order" />
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">duplicate_reverse_join.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
#
# Copyright (C) 2020 Ellen Wasboe, ellen@wasbo.net
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""
Duplicate subpaths of all selected paths, reverse and join end nodes.
"""
import inkex
class DuplicateReverseJoin(inkex.EffectExtension):
def effect(self):
for elem in self.svg.selection:
pp=elem.path.to_absolute()
dList=str(pp).split(' M')
dFinal=''
l=0
for sub in dList:
if l>0:
origSub='M'+dList[l]
else:
origSub=dList[l]
elem.path=origSub
reSub=elem.path.reverse()
if l>0:
origSub=' '+origSub
if origSub.find('Z') > -1:
dRev=str(reSub).split(' ')
strRev=''
if dRev[3]=='L' and dRev[1]==dRev[4] and dRev[2]==dRev[5]:
strRev=' '.join(dRev[0:3])+' '+' '.join(dRev[6:]) #avoid that reverse path duplicate first node
else:
strRev=' '.join(dRev)
dFinal=dFinal+origSub+' '+strRev #keep original and reverse as separate closed paths
if dRev[-1]!='Z':
dFinal=dFinal+' Z'#avoid that reverse of closed path is open
else:
dRev=str(reSub).split(' ')
dFinal=dFinal+origSub+' '+' '.join(dRev[3:])+' Z' #pop off M element of reverse path and add Z to close
l+=1
elem.path=dFinal
if __name__ == '__main__':
DuplicateReverseJoin().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Duplicate + Reverse + Join",
"id": "fablabchemnitz.de.duplicate_reverse_join",
"path": "duplicate_reverse_join",
"dependent_extensions": null,
"original_name": "Duplicate, reverse, join",
"original_id": "EllenWasbo.cutlings.duplicateReverseJoin",
"license": "GNU GPL v2",
"license_url": "https://gitlab.com/EllenWasbo/inkscape-extension-duplicatereversejoin/-/blob/master/duplicateReverseJoin.py",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/duplicate_reverse_joi",
"fork_url": "https://gitlab.com/EllenWasbo/inkscape-extension-duplicatereversejoin",
"documentation_url": "https://stadtfabrikanten.org/pages/viewpage.action?pageId=120525059",
"inkscape_gallery_url": null,
"main_authors": [
"gitlab.com/EllenWasbo",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Epilog Dashboard BBox Adjust</name>
<id>fablabchemnitz.de.epilog_dashboard_bbox_adjust</id>
<param name="tab" type="notebook">
<page name="tab_settings" gui-text="Settings">
<param name="apply_transformations" type="bool" gui-text="Apply transformations (requires separate extension)" gui-description="This will call the extension 'Apply Transformations'. Helps avoiding geometry shifting">false</param>
<param name="offset" type="float" min="0.0" max="1000.0" precision="3" gui-text="XY Offset (mm) from top left corner">1.0</param>
<param name="removal" gui-text="Element removal" type="optiongroup" appearance="combo" gui-description="Remove all elements outside the bounding box or selection. PObjects partially outside the canvas will be dropped too in case you selected 'outside of canvas'">
<option value="none">none</option>
<option value="outside_canvas">outside of canvas</option>
<option value="outside_selection">outside of selection</option>
</param>
<param name="use_machine_size" type="bool" gui-text="Use machine size (else use symmetric border)">false</param>
<param name="machine_size" gui-text="Machine/Size (mm)" type="optiongroup" appearance="combo">
<option value="406x305">406 x 305 mm (Zing 16)</option>
<option value="610x305">610 x 305 mm (Zing 24 / Fusion Edge 12)</option>
<option value="812x508">812 x 508 mm (Fusion Pro 32 / Fusion M2 32)</option>
<option value="1016x711">1016 x 711 mm (Fusion M2 40)</option>
<option value="1219x914">1219 x 914 mm (Fusion Pro 48)</option>
</param>
<param name="debug" type="bool" gui-text="Debug output">false</param>
<param name="skip_errors" type="bool" gui-text="Skip on errors">false</param>
</page>
<page name="tab_about" gui-text="About">
<label appearance="header">Epilog Dashboard BBox Adjust</label>
<label>Widen the document to send all lines properly to Epilog Dashboard. Note: If your selection is empty the whole document will be handled.</label>
<label>2021 - 2022 / written by Mario Voigt (Stadtfabrikanten e.V. / FabLab Chemnitz)</label>
<spacer/>
<label appearance="header">Online Documentation</label>
<label appearance="url">https://y.stadtfabrikanten.org/epilogbboxadjust</label>
<spacer/>
<label appearance="header">Contributing</label>
<label appearance="url">https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X</label>
<label appearance="url">mailto:mario.voigt@stadtfabrikanten.org</label>
<spacer/>
<label appearance="header">MightyScape Extension Collection</label>
<label>This piece of software is part of the MightyScape for Inkscape Extension Collection and is licensed under GNU GPL v3</label>
<label appearance="url">https://y.stadtfabrikanten.org/mightyscape-overview</label>
</page>
<page name="tab_donate" gui-text="Donate">
<label appearance="header">Coffee + Pizza</label>
<label>We are the Stadtfabrikanten, running the FabLab Chemnitz since 2016. A FabLab is an open workshop that gives people access to machines and digital tools like 3D printers, laser cutters and CNC milling machines.</label>
<spacer/>
<label>You like our work and want to support us? You can donate to our non-profit organization by different ways:</label>
<label appearance="url">https://y.stadtfabrikanten.org/donate</label>
<spacer/>
<label>Thanks for using our extension and helping us!</label>
<image>../000_about_fablabchemnitz.svg</image>
</page>
</param>
<effect needs-document="true" needs-live-preview="true">
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Transformations" />
</submenu>
</effects-menu>
<menu-tip>Widen the document to send all lines properly to Epilog Dashboard</menu-tip>
</effect>
<script>
<command location="inx" interpreter="python">epilog_dashboard_bbox_adjust.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
'''
Extension for InkScape 1.0
Features
- This tool is a helper to adjust the document border including an offset value, which is added.
Sending vector data to Epilog Dashboard often results in trimmed paths. This leads to wrong geometry where the laser misses to cut them.
So we add a default (small) amount of 1.0 doc units to expand the document's canvas
Author: Mario Voigt / FabLab Chemnitz
Mail: mario.voigt@stadtfabrikanten.org
Date: 21.04.2021
Last patch: 12.10.2022
License: GNU GPL v3
#known bugs:
- viewbox/width/height do not correctly apply if documents only contain an object (not a path). After converting it to path it works. Seems to be a bbox problem
- note from 07.05.2021: seems if the following order is viewBox/width/height, or width/height/viewBox, the units are not respected. So me mess around a little bit
#Todo
- add some way to handle translations properly
'''
import math
import sys
import inkex
from inkex import Transform
sys.path.append("../apply_transformations")
class EpilogDashboardBboxAdjust(inkex.EffectExtension):
def getElementChildren(self, element, elements = None):
if elements == None:
elements = []
if element.tag != inkex.addNS('g','svg'):
elements.append(element)
for child in element.getchildren():
self.getElementChildren(child, elements)
return elements
def add_arguments(self, pars):
pars.add_argument("--tab")
pars.add_argument("--apply_transformations", type=inkex.Boolean, default=False, help="Run 'Apply Transformations' extension before running vpype. Helps avoiding geometry shifting")
pars.add_argument("--offset", type=float, default="1.0", help="XY Offset (mm) from top left corner")
pars.add_argument("--removal", default="none", help="Remove all elements outside the bounding box or selection")
pars.add_argument("--use_machine_size", type=inkex.Boolean, default=False, help="Use machine size")
pars.add_argument("--machine_size", default="812x508", help="Machine/Size (mm)")
pars.add_argument("--debug", type=inkex.Boolean, default=False, help="Debug output")
pars.add_argument("--skip_errors", type=inkex.Boolean, default=False, help="Skip on errors")
def effect(self):
applyTransformationsAvailable = False # at first we apply external extension
try:
import apply_transformations
applyTransformationsAvailable = True
except Exception as e:
# self.msg(e)
self.msg("Calling 'Apply Transformations' extension failed. Maybe the extension is not installed. You can download it from official InkScape Gallery. Skipping ...")
if self.options.apply_transformations is True and applyTransformationsAvailable is True:
apply_transformations.ApplyTransformations().recursiveFuseTransform(self.document.getroot())
offset = self.options.offset
units = self.svg.unit
#https://wiki.inkscape.org/wiki/Units_In_Inkscape
# create a new bounding box and get the bbox size of all elements of the document (we cannot use the page's bbox)
bbox = inkex.BoundingBox()
if len(self.svg.selected) > 0:
#bbox = self.svg.selection.bounding_box() #it could be so easy! But ...
for element in self.svg.selected.values():
if isinstance (element, inkex.TextElement) or \
isinstance (element, inkex.Tspan):
if self.options.skip_errors is False:
self.msg("Text elements are not supported!")
return
else:
continue
else:
bbox += element.bounding_box()
else:
#for element in self.svg.root.getchildren():
for element in self.document.getroot().iter("*"):
if isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('use','svg') and element.get('inkscape:groupmode') != 'layer': #bbox fails for svg:use elements and layers:
if isinstance (element, inkex.TextElement) or \
isinstance (element, inkex.Tspan):
if self.options.skip_errors is False:
self.msg("Text elements are not supported!")
return
else:
continue
else:
bbox += element.bounding_box()
if abs(bbox.width) == math.inf or abs(bbox.height) == math.inf:
inkex.utils.debug("Error while calculating overall bounding box! Check your element types. Things like svg:text or svg:use are not supported. Impossible to continue!")
return
# adjust the viewBox to the bbox size and add the desired offset
if self.options.use_machine_size is True:
machineWidth = self.svg.unittouu(self.options.machine_size.split('x')[0] + "mm")
machineHeight = self.svg.unittouu(self.options.machine_size.split('x')[1] + "mm")
width = f'{machineWidth}' + units
height = f'{machineHeight}' + units
viewBoxXmin = -offset
viewBoxYmin = -offset
viewBoxXmax = machineWidth
viewBoxYmax = machineHeight
else:
width = f'{bbox.width + offset * 2}' + units
height = f'{bbox.height + offset * 2}' + units
viewBoxXmin = -offset
viewBoxYmin = -offset
viewBoxXmax = bbox.width + offset * 2
viewBoxYmax = bbox.height + offset * 2
self.document.getroot().attrib['width'] = width
self.document.getroot().attrib['viewBox'] = f'{viewBoxXmin} {viewBoxYmin} {viewBoxXmax} {viewBoxYmax}'
self.document.getroot().attrib['height'] = height
# translate all elements to fit the adjusted viewBox
mat = Transform("translate(%f, %f)" % (-bbox.left,-bbox.top))
for element in self.document.getroot().iter("*"):
if isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('g', 'svg'):
element.transform = Transform(mat) @ element.composed_transform()
if self.options.removal == "outside_canvas":
for element in self.document.getroot().iter("*"):
if isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('g', 'svg'):
ebbox = element.bounding_box()
#self.msg("{} | bbox: left = {:0.3f} right = {:0.3f} top = {:0.3f} bottom = {:0.3f}".format(element.get('id'), ebbox.left, ebbox.right, ebbox.top, ebbox.bottom))
#check if the element's bbox is inside the view canvas. If not: delete it!
if ebbox.right < viewBoxXmin or \
ebbox.left > viewBoxXmax or \
ebbox.top < viewBoxYmin or \
ebbox.bottom > viewBoxYmax:
if self.options.debug is True:
self.msg("Removing {} {}".format(element.get('id'), ebbox))
element.delete()
elif self.options.removal == "outside_selection":
if len(self.svg.selected) == 0:
inkex.utils.debug("Your selection is empty but you have chosen the option to remove all elements outside selection!")
return
allElements = []
for selected in self.svg.selection:
allElements = self.getElementChildren(selected, allElements)
for element in self.document.getroot().iter("*"):
if element not in allElements and isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('g', 'svg'):
if self.options.debug is True:
self.msg("Removing {}".format(element.get('id')))
element.delete()
if __name__ == '__main__':
EpilogDashboardBboxAdjust().run()

View File

@ -0,0 +1,22 @@
[
{
"name": "Epilog Dashboard BBox Adjust",
"id": "fablabchemnitz.de.epilog_dashboard_bbox_adjust",
"path": "epilog_dashboard_bbox_adjust",
"dependent_extensions": [
"apply_transformations"
],
"original_name": "Epilog Dashboard BBox Adjust",
"original_id": "fablabchemnitz.de.epilog_dashboard_bbox_adjust",
"license": "GNU GPL v3",
"license_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/LICENSE",
"comment": "Written by Mario Voigt",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust",
"fork_url": null,
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Epilog+Dashboard+BBox+Adjust",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Frame Animation Sequence</name>
<id>fablabchemnitz.de.frame_animation_sequence</id>
<param name="begin_str" type="string" gui-text="begin_str:">0</param>
<param name="repeat_str" type="string" gui-text="repeat_str:" gui-description="Enter 'indefinite' or integer value">indefinite</param>
<param name="dur_str" type="string" gui-text="dur_str:">9.1</param>
<param name="delete" type="bool" gui-text="Remove frames">true</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Animation"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">frame_animation_sequence.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
#
# Copyright (C) 2021 roberta bennett repeatingshadow@protonmail.com
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
Create svg path animation as SMIL from frames.
Place each frame of the animation in a layer named 'frame'.
Each of these layers should have the same number of paths,
and each path should have the same number of points as the corresponding
path in other layers.
Note if there are more than one path in the frames, the Z order of the paths
must match. It helps to use the XML editor option to observe the z order.
The animation is applied to the paths in the first layer in the sequence, so the
properties of that layer are used. In particular, the first layer ought to be set
visible.
Animations with different numbers of frames can be put into different sequences,
named 'sequence', using sub-groups:
Layers:
not_animated_layer1
sequence
frame
path1a
path2a
frame
path1b
path2b
frame
path1c
path2c
frame
path1d
path2d
sequence
frame
frame
frame
Layer names must contain 'frame' and groups names contain 'sequence',
eg, frame1 frame 2 frame 30, sequence 1, rythm sequence, rocket sequence
"""
import inkex
class AnimateElement(inkex.BaseElement):
"""animation Elements do not have a visible representation on the canvas"""
tag_name = 'animate'
@classmethod
def new(cls, **attrs):
return super().new( **attrs)
class AnimationExtension(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument("--delete", type=inkex.Boolean, help="Remove frames")
pars.add_argument("--begin_str", default="0", help="begin string: eg 0;an2.end;an3.begin")
pars.add_argument("--repeat_str", default="indefinite", help="indefinite or an integer")
pars.add_argument("--dur_str", default="7.9", help="duration in seconds. Do not decorate with units")
def crunchFrames(self,frames):
if frames is None:
raise inkex.AbortExtension("layer named frame does not exist.")
frame0paths = frames[0].findall('svg:path')
Dlists = ["{}".format(p.get_path()) for p in frame0paths]
for frame in frames[1:]:
paths = frame.findall("svg:path")
for i, p in enumerate(paths):
Dlists[i] += ";\n{}".format(p.get_path())
for i, dl in enumerate(Dlists):
animel = AnimateElement(
attributeName="d",
attributeType="XML",
begin=self.options.begin_str,
dur=self.options.dur_str,
repeatCount=self.options.repeat_str,
values=dl)
frame0paths[i].append(animel)
for frame in frames[1:]:
if self.options.delete:
frame.delete()
return
def effect(self):
sequences = [ elem for elem in self.svg.findall("svg:g[@inkscape:label]")
if "sequence" in (elem.get('inkscape:label'))
]
if len(sequences)==0:
frames = [ elem for elem in self.svg.findall("svg:g[@inkscape:label]")
if "frame" in (elem.get('inkscape:label'))
]
self.crunchFrames(frames)
return
for sequence in sequences:
frames = [ elem for elem in sequence.findall("svg:g[@inkscape:label]")
if "frame" in (elem.get('inkscape:label'))
]
self.crunchFrames(frames)
if __name__ == '__main__':
AnimationExtension().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Frame Animation Sequence",
"id": "fablabchemnitz.de.frame_animation_sequence",
"path": "frame_animation_sequence",
"dependent_extensions": null,
"original_name": "Animate Extension",
"original_id": "org.inkscape.katkitty.animate",
"license": "GNU GPL v2",
"license_url": "https://github.com/yttiktak/inkscape_extension_animate/blob/main/AnimationExtension.py",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/frame_animation_sequence",
"fork_url": "https://github.com/yttiktak/inkscape_extension_animate",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Frame+Animation+Sequence",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/yttiktak",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Guillotine Plus</name>
<id>fablabchemnitz.de.guillotine_plus</id>
<param name="directory" type="path" mode="folder" gui-text="Directory to save images to:">~/</param>
<param name="image" type="string" gui-text="Image name (without extension):">guillotined</param>
<param name="dpi" type="int" gui-text="DPI" min="1" max="10000">300</param>
<param name="ignore" type="bool" gui-text="Ignore these settings and use export hints">false</param>
<effect needs-live-preview="false">
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Import/Export/Transfer"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">guillotine_plus.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
#
# Copyright (C) 2010 Craig Marshall, craig9 [at] gmail.com
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
"""
This script slices an inkscape drawing along the guides, similarly to
the GIMP plugin called "guillotine". It can optionally export to the
same directory as the SVG file with the same name, but with a number
suffix. e.g.
/home/foo/drawing.svg
will export to:
/home/foo/drawing0.png
/home/foo/drawing1.png
/home/foo/drawing2.png
/home/foo/drawing3.png
etc.
"""
import os
import locale
import inkex
from inkex.command import inkscape
class GuillotinePlus(inkex.EffectExtension):
"""Exports slices made using guides"""
def add_arguments(self, pars):
pars.add_argument("--directory")
pars.add_argument("--image")
pars.add_argument("--ignore", type=inkex.Boolean)
pars.add_argument("--dpi", type=int)
def get_all_horizontal_guides(self):
"""
Returns all horizontal guides as a list of floats stored as
strings. Each value is the position from 0 in pixels.
"""
for guide in self.svg.namedview.get_guides():
if guide.is_horizontal:
yield guide.point.y
def get_all_vertical_guides(self):
"""
Returns all vertical guides as a list of floats stored as
strings. Each value is the position from 0 in pixels.
"""
for guide in self.svg.namedview.get_guides():
if guide.is_vertical:
yield guide.point.x
def get_horizontal_slice_positions(self):
"""
Make a sorted list of all horizontal guide positions,
including 0 and the document height, but not including
those outside of the canvas
"""
horizontals = [0.0]
height = float(self.svg.viewport_height)
for y in self.get_all_horizontal_guides():
if 0.0 < y <= height:
horizontals.append(y)
horizontals.append(height)
return sorted(horizontals)
def get_vertical_slice_positions(self):
"""
Make a sorted list of all vertical guide positions,
including 0 and the document width, but not including
those outside of the canvas.
"""
verticals = [0.0]
width = float(self.svg.viewport_width)
for x in self.get_all_vertical_guides():
if 0.0 < x <= width:
verticals.append(x)
verticals.append(width)
return sorted(verticals)
def get_slices(self):
"""
Returns a list of all "slices" as denoted by the guides
on the page. Each slice is really just a 4 element list of
floats (stored as strings), consisting of the X and Y start
position and the X and Y end position.
"""
hs = self.get_horizontal_slice_positions()
vs = self.get_vertical_slice_positions()
slices = []
for i in range(len(hs) - 1):
for j in range(len(vs) - 1):
slices.append([vs[j], hs[i], vs[j + 1], hs[i + 1]])
return slices
def get_filename_parts(self):
"""
Attempts to get directory and image as passed in by the inkscape
dialog. If the boolean ignore flag is set, then it will ignore
these settings and try to use the settings from the export
filename.
"""
if not self.options.ignore:
if self.options.image == "" or self.options.image is None:
raise inkex.AbortExtension("Please enter an image name")
return self.options.directory, self.options.image
else:
'''
First get the export-filename from the document, if the
document has been exported before (TODO: Will not work if it
hasn't been exported yet), then uses this to return a tuple
consisting of the directory to export to, and the filename
without extension.
'''
warning = \
"To use the export hints option, you " + \
"need to have previously exported the document. " + \
"Otherwise no export hints exist!"
try:
export_file = self.svg.get('inkscape:export-filename')
except KeyError:
raise inkex.AbortExtension(warning)
if export_file is None:
raise inkex.AbortExtension(warning)
dirname, filename = os.path.split(export_file)
filename = filename.rsplit(".", 1)[0] # Without extension
return dirname, filename
def get_localised_string(self, name):
return locale.format_string("%.f", float(name), 0)
def export_slice(self, sli, filename):
"""
Runs inkscape's command line interface and exports the image
slice from the 4 coordinates in s, and saves as the filename
given.
"""
coords = ":".join([self.get_localised_string(dim) for dim in sli])
inkscape(self.options.input_file, export_dpi=self.options.dpi, export_area=coords, export_filename=filename)
def export_slices(self, slices):
"""
Takes the slices list and passes each one with a calculated
filename/directory into export_slice.
"""
dirname, filename = self.get_filename_parts()
# Remove some crusty extensions from name template
if filename.endswith('.svg') or filename.endswith('.png'):
filename = filename.rsplit('.', 1)[0]
if '{' not in filename:
filename += '_{}'
dirname = os.path.abspath(os.path.expanduser(os.path.expandvars(dirname or './')))
if not os.path.isdir(dirname):
try:
os.makedirs(dirname)
except IOError:
raise inkex.AbortExtension("Target directory could not be created. Change and try again!")
output_files = []
for i, slico in enumerate(slices):
fname = os.path.join(dirname, filename.format(i) + '.png')
output_files.append(fname)
self.export_slice(slico, fname)
#self.debug("The sliced bitmaps have been saved as:" + "\n\n" + "\n".join(output_files))
def effect(self):
self.export_slices(self.get_slices())
if __name__ == '__main__':
GuillotinePlus().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Guillotine Plus",
"id": "fablabchemnitz.de.guillotine_plus",
"path": "guillotine_plus",
"dependent_extensions": null,
"original_name": "Guillotine Plus",
"original_id": "org.inkscape.guillotineplus",
"license": "GNU GPL v2",
"license_url": "https://inkscape.org/de/~Bootta/%E2%98%85guillotine-plus",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/guillotine_plus",
"fork_url": "https://inkscape.org/de/~Bootta/%E2%98%85guillotine-plus",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Guillotine+Plus",
"inkscape_gallery_url": null,
"main_authors": [
"inkscape.org/Bootta",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Import Attributes</name>
<id>fablabchemnitz.de.import_attributes</id>
<label>Uses lines in text file to edit attributes of elements.</label>
<label>Line: 'elementID,attributeName,attributeValue'.</label>
<label>For namespaces use {namespaceUrl}attributeName</label>
<param name="data" type="path" gui-text="Data file:" mode="file" filetypes="txt,csv"/>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Various"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">import_attributes.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
import inkex
import os
import lxml
class ImportAttributes(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument("--data", default="", help="data file")
def effect(self):
if not os.path.exists(self.options.data):
self.msg("The input file does not exist. Please select a proper file and try again.")
exit(1)
if os.path.isdir(self.options.data):
self.msg("You must specify a file, not a directory!")
exit(1)
with open(self.options.data, 'r') as f:
lines = f.read().splitlines()
for line in lines:
#split on , max 2+1 = 3 items
parts = line.split(",", 2)
if len(parts) >= 3:
id = parts[0]
attribute = parts[1]
value = parts[2]
try:
node = self.svg.getElementById(id)
if node is not None:
try:
node.set(attribute, value)
except AttributeError:
inkex.utils.debug("Unknown Attribute")
except AttributeError:
inkex.utils.debug("element with id '" + id + "' not found in current selection.")
except lxml.etree.XPathEvalError:
inkex.utils.debug("invalid input file")
exit(1)
if __name__ == '__main__':
ImportAttributes().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Import Attributes",
"id": "fablabchemnitz.de.import_attributes",
"path": "import_attributes",
"dependent_extensions": null,
"original_name": "Import Attributes",
"original_id": "com.mathem.attrib_import.py",
"license": "GNU LGPL v2",
"license_url": "https://inkscape.org/~MatheM/%E2%98%85simple-attribute-editor+1",
"comment": "ported to Inkscape v1 manually by Mario Voigt",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/import_attributes",
"fork_url": "https://inkscape.org/~MatheM/%E2%98%85simple-attribute-editor+1",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Import+Attributes",
"inkscape_gallery_url": null,
"main_authors": [
"inkscape.org/MatheM",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Dimetric Projection</name>
<id>fablabchemnitz.de.dimetric_projection</id>
<param name="conversion" type="optiongroup" appearance="combo" gui-text="Convert flat projection to">
<option value="top">Dimetric top side</option>
<option value="left">Dimetric left-hand side</option>
<option value="right">Dimetric right-hand side</option>
</param>
<param name="reverse" type="bool" gui-text="Reverse transformation">false</param>
<param name="orthoangle" type="float" precision="3" min="0.000" max="90.000" gui-text="Orthographic angle">15.000</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Transformations" />
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">isometric_projection.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Isometric Projection</name>
<id>fablabchemnitz.de.isometric_projection</id>
<param name="conversion" type="optiongroup" appearance="combo" gui-text="Convert flat projection to">
<option value="top">Isometric top side</option>
<option value="left">Isometric left-hand side</option>
<option value="right">Isometric right-hand side</option>
</param>
<param name="reverse" type="bool" gui-text="Reverse transformation">false</param>
<param name="orthoangle" type="float" precision="3" min="0" max="90" gui-text="Orthographic angle">30.000</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Transformations"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">isometric_projection.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,218 @@
#!/usr/bin/env python3
import math
import inkex
from inkex.transforms import Transform
class IsometricProjection(inkex.EffectExtension):
"""
Convert a flat 2D projection to one of the three visible sides in an
isometric projection, and vice versa.
"""
attrTransformCenterX = inkex.addNS('transform-center-x', 'inkscape')
attrTransformCenterY = inkex.addNS('transform-center-y', 'inkscape')
# Precomputed values for sine, cosine, and tangent of 30°.
rad_30 = math.radians(30)
cos_30 = math.cos(rad_30)
sin_30 = 0.5 # No point in using math.sin for 30°.
tan_30 = math.tan(rad_30)
# Combined affine transformation matrices. The bottom row of these 3×3
# matrices is omitted; it is always [0, 0, 1].
transformations = {
# From 2D to isometric top down view:
# * scale vertically by cos(30°)
# * shear horizontally by -30°
# * rotate clock-wise 30°
'to_top': [[cos_30, -cos_30, 0],
[sin_30, sin_30, 0]],
# From 2D to isometric left-hand side view:
# * scale horizontally by cos(30°)
# * shear vertically by -30°
'to_left': [[cos_30, 0, 0],
[sin_30, 1, 0]],
# From 2D to isometric right-hand side view:
# * scale horizontally by cos(30°)
# * shear vertically by 30°
'to_right': [[cos_30, 0, 0],
[-sin_30, 1, 0]],
# From isometric top down view to 2D:
# * rotate counter-clock-wise 30°
# * shear horizontally by 30°
# * scale vertically by 1 / cos(30°)
'from_top': [[tan_30, 1, 0],
[-tan_30, 1, 0]],
# From isometric left-hand side view to 2D:
# * shear vertically by 30°
# * scale horizontally by 1 / cos(30°)
'from_left': [[1 / cos_30, 0, 0],
[-tan_30, 1, 0]],
# From isometric right-hand side view to 2D:
# * shear vertically by -30°
# * scale horizontally by 1 / cos(30°)
'from_right': [[1 / cos_30, 0, 0],
[tan_30, 1, 0]]
}
def add_arguments(self, pars):
pars.add_argument('--conversion', default='top', help='Conversion to perform: (top|left|right)')
pars.add_argument('--reverse', type=inkex.Boolean, default=False, help='Reverse the transformation from isometric projection to flat 2D')
self.arg_parser.add_argument('--orthoangle', type=float, default=15.0, help='Isometric angle in degrees')
def __initConstants(self, angle):
# Precomputed values for sine, cosine, and tangent of orthoangle.
self.rad = math.radians(angle)
self.cos = math.cos(self.rad)
self.sin = math.sin(self.rad)
self.tan = math.tan(self.rad)
# Combined affine transformation matrices. The bottom row of these 3×3
# matrices is omitted; it is always [0, 0, 1].
self.transformations = {
# From 2D to isometric top down view:
# * scale vertically by cos(∠)
# * shear horizontally by -∠
# * rotate clock-wise ∠
'to_top': Transform(((self.cos, -self.cos, 0),
(self.sin, self.sin, 0))),
# From 2D to isometric left-hand side view:
# * scale horizontally by cos(∠)
# * shear vertically by -∠
'to_left': Transform(((self.cos, 0, 0),
(self.sin, 1, 0))),
# From 2D to isometric right-hand side view:
# * scale horizontally by cos(∠)
# * shear vertically by ∠
'to_right': Transform(((self.cos , 0, 0),
(-self.sin, 1, 0))),
# From isometric top down view to 2D:
# * rotate counter-clock-wise orthoangle
# * shear horizontally by orthoangle
# * scale vertically by 1 / cos(orthoangle)
'from_top': [[self.tan , 1, 0],
[-self.tan, 1, 0]],
# From isometric left-hand side view to 2D:
# * shear vertically by orthoangle
# * scale horizontally by 1 / cos(orthoangle)
'from_left': [[1 / self.cos, 0, 0],
[-self.tan, 1, 0]],
# From isometric right-hand side view to 2D:
# * shear vertically by -orthoangle
# * scale horizontally by 1 / cos(orthoangle)
'from_right': [[1 / self.cos, 0, 0],
[self.tan, 1, 0]]
}
# The inverse matrices of the above perform the reverse transformations.
self.transformations['from_top'] = -self.transformations['to_top']
self.transformations['from_left'] = -self.transformations['to_left']
self.transformations['from_right'] = -self.transformations['to_right']
def getTransformCenter(self, midpoint, node):
"""
Find the transformation center of an object. If the user set it
manually by dragging it in Inkscape, those coordinates are used.
Otherwise, an attempt is made to find the center of the object's
bounding box.
"""
c_x = node.get(self.attrTransformCenterX)
c_y = node.get(self.attrTransformCenterY)
# Default to dead-center.
if c_x is None:
c_x = 0.0
else:
c_x = float(c_x)
if c_y is None:
c_y = 0.0
else:
c_y = float(c_y)
x = midpoint[0] + c_x
y = midpoint[1] - c_y
return [x, y]
def translateBetweenPoints(self, tr, here, there):
"""
Add a translation to a matrix that moves between two points.
"""
x = there[0] - here[0]
y = there[1] - here[1]
tr.add_translate(x, y)
def moveTransformationCenter(self, node, midpoint, center_new):
"""
If a transformation center is manually set on the node, move it to
match the transformation performed on the node.
"""
c_x = node.get(self.attrTransformCenterX)
c_y = node.get(self.attrTransformCenterY)
if c_x is not None:
x = str(center_new[0] - midpoint[0])
node.set(self.attrTransformCenterX, x)
if c_y is not None:
y = str(midpoint[1] - center_new[1])
node.set(self.attrTransformCenterY, y)
def effect(self):
self.__initConstants(self.options.orthoangle)
if self.options.reverse is True:
conversion = "from_" + self.options.conversion
else:
conversion = "to_" + self.options.conversion
if len(self.svg.selected) == 0:
inkex.errormsg("Please select an object to perform the " +
"isometric projection transformation on.")
return
# Default to the flat 2D to isometric top down view conversion if an
# invalid identifier is passed.
effect_matrix = self.transformations.get(
conversion, self.transformations.get('to_top'))
for id, node in self.svg.selected.items():
bbox = node.bounding_box()
midpoint = [bbox.center_x, bbox.center_y]
center_old = self.getTransformCenter(midpoint, node)
transform = Transform(node.get("transform"))
# Combine our transformation matrix with any pre-existing transform.
tr = transform @ effect_matrix
# Compute the location of the transformation center after applying
# the transformation matrix.
center_new = center_old[:]
#Transform(matrix).apply_to_point(center_new)
tr.apply_to_point(center_new)
tr.apply_to_point(midpoint)
# Add a translation transformation that will move the object to
# keep its transformation center in the same place.
self.translateBetweenPoints(tr, center_new, center_old)
node.set('transform', str(tr))
# Adjust the transformation center.
self.moveTransformationCenter(node, midpoint, center_new)
if __name__ == '__main__':
IsometricProjection().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Isometric Projection",
"id": "fablabchemnitz.de.isometric_projection",
"path": "isometric_projection",
"dependent_extensions": null,
"original_name": "Isometric Projection",
"original_id": "nl.jeroenhoek.inkscape.filter.isometric_projection_tool",
"license": "GNU GPL v3",
"license_url": "https://github.com/jdhoek/inkscape-isometric-projection/blob/master/LICENSE",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/isometric_projection",
"fork_url": "https://github.com/jdhoek/inkscape-isometric-projection",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Isometric+Projection",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/jdhoek",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Low Poly 2</name>
<id>fablabchemnitz.de.low_poly_2</id>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Shape/Pattern from existing Object(s)"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">low_poly_2.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,301 @@
#!/usr/bin/env python3
import os
import inkex
import voronoi
from inkex.transforms import Transform
from inkex.paths import CubicSuperPath, Path
from PIL import Image
from lxml import etree
import base64
from io import BytesIO
import urllib.request as urllib
# A tool for making polygonal art. Can be created with one click with a pass.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __lt__(self,other):
return (self.x*self.y<other.x*other.y)
def __le__(self,other):
return (self.x*self.y<=other.y*other.y)
def __gt__(self,other):
return (self.x*self.y>other.x*other.y)
def __ge__(self,other):
return (self.x*self.y>=other.x*other.y)
def __eq__(self,other):
return (self.x==other.x) and (self.y==other.y)
def __ne__(self,other):
return (self.x!=other.x) or (self.y!=other.y)
def __str__(self):
return "("+str(self.x)+","+str(self.y)+")"
class LowPoly2(inkex.Effect):
def __init__(self):
inkex.Effect.__init__(self)
# Clipping a line by a bounding box
def dot(self, x, y):
return x[0] * y[0] + x[1] * y[1]
def intersectLineSegment(self, line, v1, v2):
s1 = self.dot(line, v1) - line[2]
s2 = self.dot(line, v2) - line[2]
if s1 * s2 > 0:
return (0, 0, False)
else:
tmp = self.dot(line, v1) - self.dot(line, v2)
if tmp == 0:
return (0, 0, False)
u = (line[2] - self.dot(line, v2)) / tmp
v = 1 - u
return (u * v1[0] + v * v2[0], u * v1[1] + v * v2[1], True)
def clipEdge(self, vertices, lines, edge, bbox):
# bounding box corners
bbc = []
bbc.append((bbox[0], bbox[2]))
bbc.append((bbox[1], bbox[2]))
bbc.append((bbox[1], bbox[3]))
bbc.append((bbox[0], bbox[3]))
# record intersections of the line with bounding box edges
line = (lines[edge[0]])
interpoints = []
for i in range(4):
p = self.intersectLineSegment(line, bbc[i], bbc[(i + 1) % 4])
if (p[2]):
interpoints.append(p)
# if the edge has no intersection, return empty intersection
if (len(interpoints) < 2):
return []
if (len(interpoints) > 2): #h appens when the edge crosses the corner of the box
interpoints = list(set(interpoints)) # remove doubles
# points of the edge
v1 = vertices[edge[1]]
interpoints.append((v1[0], v1[1], False))
v2 = vertices[edge[2]]
interpoints.append((v2[0], v2[1], False))
# sorting the points in the widest range to get them in order on the line
minx = interpoints[0][0]
maxx = interpoints[0][0]
miny = interpoints[0][1]
maxy = interpoints[0][1]
for point in interpoints:
minx = min(point[0], minx)
maxx = max(point[0], maxx)
miny = min(point[1], miny)
maxy = max(point[1], maxy)
if (maxx - minx) > (maxy - miny):
interpoints.sort()
else:
interpoints.sort(key=lambda pt: pt[1])
start = []
inside = False #true when the part of the line studied is in the clip box
startWrite = False #true when the part of the line is in the edge segment
for point in interpoints:
if point[2]: #The point is a bounding box intersection
if inside:
if startWrite:
return [[start[0], start[1]], [point[0], point[1]]]
else:
return []
else:
if startWrite:
start = point
inside = not inside
else: # The point is a segment endpoint
if startWrite:
if inside:
# a vertex ends the line inside the bounding box
return [[start[0], start[1]], [point[0], point[1]]]
else:
return []
else:
if inside:
start = point
startWrite = not startWrite
# Transformation helpers
def invertTransform(self, mat):
det = mat[0][0] * mat[1][1] - mat[0][1] * mat[1][0]
if det != 0: #det is 0 only in case of 0 scaling
# invert the rotation/scaling part
a11 = mat[1][1] / det
a12 = -mat[0][1] / det
a21 = -mat[1][0] / det
a22 = mat[0][0] / det
# invert the translational part
a13 = -(a11 * mat[0][2] + a12 * mat[1][2])
a23 = -(a21 * mat[0][2] + a22 * mat[1][2])
return [[a11, a12, a13], [a21, a22, a23]]
else:
return [[0, 0, -mat[0][2]], [0, 0, -mat[1][2]]]
def getGlobalTransform(self, node):
parent = node.getparent()
myTrans = Transform(node.get('transform')).matrix
if myTrans:
if parent is not None:
parentTrans = self.getGlobalTransform(parent)
if parentTrans:
return Transform(parentTrans) * Transform(myTrans)
else:
return myTrans
else:
if parent is not None:
return self.getGlobalTransform(parent)
else:
return None
def checkImagePath(self, node):
"""Embed the data of the selected Image Tag element"""
xlink = node.get('xlink:href')
if xlink and xlink[:5] == 'data:':
# No need, data alread embedded
return
url = urllib.urlparse(xlink)
href = urllib.url2pathname(url.path)
# Primary location always the filename itself.
path = self.absolute_href(href or '')
# Backup directory where we can find the image
if not os.path.isfile(path):
path = node.get('sodipodi:absref', path)
if not os.path.isfile(path):
inkex.errormsg('File not found "{}". Unable to embed image.').format(path)
return
if (os.path.isfile(path)):
return path
def effect(self):
# Check that elements have been selected
if len(self.options.ids) == 0:
inkex.errormsg("Please select objects!")
return
# Drawing styles
linestyle = {
'stroke': '#000000',
'stroke-width': str(self.svg.unittouu('1px')),
'fill': 'none'
}
facestyle = {
'stroke': '#000000',
'stroke-width':'0px',# str(self.svg.unittouu('1px')),
'fill': 'none'
}
# Handle the transformation of the current group
parentGroup = (self.svg.selected[self.options.ids[0]]).getparent()
svg = self.document.getroot()
image_element = svg.find('.//{http://www.w3.org/2000/svg}image')
if image_element is None:
inkex.utils.debug("No image found")
exit(1)
self.path = self.checkImagePath(image_element) # This also ensures the file exists
if self.path is None: # check if image is embedded or linked
image_string = image_element.get('{http://www.w3.org/1999/xlink}href')
# find comma position
i = 0
while i < 40:
if image_string[i] == ',':
break
i = i + 1
img = Image.open(BytesIO(base64.b64decode(image_string[i + 1:len(image_string)])))
else:
img = Image.open(self.path)
extrinsic_image_width=float(image_element.get('width'))
extrinsic_image_height=float(image_element.get('height'))
(width, height) = img.size
trans = self.getGlobalTransform(parentGroup)
invtrans = None
if trans:
invtrans = self.invertTransform(trans)
# Recovery of the selected objects
pts = []
nodes = []
seeds = []
for id in self.options.ids:
node = self.svg.selected[id]
nodes.append(node)
if(node.tag=="{http://www.w3.org/2000/svg}path"):#If it is path
# Get vertex coordinates of path
points = CubicSuperPath(node.get('d'))
for p in points[0]:
pt=[p[1][0],p[1][1]]
if trans:
Transform(trans).apply_to_point(pt)
pts.append(Point(pt[0], pt[1]))
seeds.append(Point(p[1][0], p[1][1]))
else: # For other shapes
bbox = node.bounding_box()
if bbox:
cx = 0.5 * (bbox.left + bbox.top)
cy = 0.5 * (bbox.top + bbox.bottom)
pt = [cx, cy]
if trans:
Transform(trans).apply_to_point(pt)
pts.append(Point(pt[0], pt[1]))
seeds.append(Point(cx, cy))
pts.sort()
seeds.sort()
# In Creation of groups to store the result
# Delaunay
groupDelaunay = etree.SubElement(parentGroup, inkex.addNS('g', 'svg'))
groupDelaunay.set(inkex.addNS('label', 'inkscape'), 'Delaunay')
scale_x=float(extrinsic_image_width)/float(width)
scale_y=float(extrinsic_image_height)/float(height)
# Voronoi diagram generation
triangles = voronoi.computeDelaunayTriangulation(seeds)
for triangle in triangles:
p1 = seeds[triangle[0]]
p2 = seeds[triangle[1]]
p3 = seeds[triangle[2]]
cmds = [['M', [p1.x, p1.y]],
['L', [p2.x, p2.y]],
['L', [p3.x, p3.y]],
['Z', []]]
path = etree.Element(inkex.addNS('path', 'svg'))
path.set('d', str(Path(cmds)))
middleX=(p1.x+p2.x+p3.x)/3.0
middleY=(p1.y+p2.y+p3.y)/3.0
if width>middleX and height>middleY and middleX>=0 and middleY>=0:
pixelColor = img.getpixel((middleX,middleY))
facestyle["fill"]=str(inkex.Color((pixelColor[0], pixelColor[1], pixelColor[2])))
else:
facestyle["fill"]="black"
path.set('style', str(inkex.Style(facestyle)))
groupDelaunay.append(path)
if __name__ == '__main__':
LowPoly2().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Low Poly 2",
"id": "fablabchemnitz.de.low_poly_2",
"path": "low_poly_2",
"dependent_extensions": null,
"original_name": "★Delauney From Path",
"original_id": "miffy.sora.polygonalart",
"license": "MIT License",
"license_url": "https://github.com/miffysora/Inkscape/blob/master/LICENSE",
"comment": "ported to Inkscape v1 manually by Mario Voigt; delauney_from_path",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/low_poly_2",
"fork_url": "https://github.com/miffysora/Inkscape",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Low+Poly+2",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/miffysora",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,21 @@
[
{
"name": "Open In Roland CutStudio",
"id": "fablabchemnitz.de.open_in_roland_cutstudio",
"path": "open_in_roland_cutstudio",
"dependent_extensions": null,
"original_name": "Open in CutStudio",
"original_id": "roland_custudio.export",
"license": "GNU GPL v2",
"license_url": "https://github.com/mgmax/inkscape-roland-cutstudio/blob/master/roland_cutstudio.py",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/open_in_roland_cutstudio",
"fork_url": "https://github.com/mgmax/inkscape-roland-cutstudio",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Open+in+Roland+CutStudio",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/mgmax",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Open In Roland CutStudio</name>
<id>fablabchemnitz.de.open_in_roland_cutstudio</id>
<effect needs-live-preview="false">
<object-type>path</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Import/Export/Transfer"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">open_in_roland_cutstudio.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,435 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-or-later
'''
Roland CutStudio export script
Copyright (C) 2014 - 2020 Max Gaukler <development@maxgaukler.de>
skeleton based on Visicut Inkscape Plugin :
Copyright (C) 2012 Thomas Oster, thomas.oster@rwth-aachen.de
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
'''
# The source code is a horrible mess. I apologize for your inconvenience, but hope that it still helps. Feel free to improve :-)
# Keep everything python2 compatible as long as people out there are using Inkscape <= 0.92.4!
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from __future__ import absolute_import
from builtins import open
from builtins import map
from builtins import str
from builtins import range
import sys
import os
from subprocess import Popen
import subprocess
import shutil
import numpy
from functools import reduce
import atexit
import filecmp
try:
from pathlib import Path
except ImportError:
# Workaround for Python < 3.5
class fakepath:
def home(self):
return os.path.expanduser("~")
Path = fakepath()
import tempfile
import random
import string
DEVNULL = open(os.devnull, 'w')
atexit.register(DEVNULL.close)
def message(s):
sys.stderr.write(s+"\n")
def debug(s):
message(s)
# copied from https://github.com/t-oster/VisiCut/blob/0abe785a30d5d5085dd3b5953b38239b1ff83358/tools/inkscape_extension/visicut_export.py
def which(program, raiseError, extraPaths=[], subdir=None):
"""
find program in the $PATH environment variable and in $extraPaths.
If $subdir is given, also look in the given subdirectory of each $PATH entry.
"""
pathlist=os.environ["PATH"].split(os.pathsep)
if "nt" in os.name:
pathlist.append(os.environ.get("ProgramFiles","C:\Program Files\\"))
pathlist.append(os.environ.get("ProgramFiles(x86)","C:\Program Files (x86)\\"))
pathlist.append("C:\Program Files\\") # needed for 64bit inkscape on 64bit Win7 machines
pathlist.append(os.path.dirname(os.path.dirname(os.getcwd()))) # portable application in the current directory
pathlist += extraPaths
if subdir:
pathlist = [os.path.join(p, subdir) for p in pathlist] + pathlist
def is_exe(fpath):
return os.path.isfile(fpath) and (os.access(fpath, os.X_OK) or fpath.endswith(".exe"))
for path in pathlist:
exe_file = os.path.join(path, program)
if is_exe(exe_file):
return exe_file
if raiseError:
raise Exception("Cannot find " + str(program) + " in any of these paths: " + str(pathlist) + ". Either the program is not installed, PATH is not set correctly, or this is a bug.")
else:
return None
# copied from https://github.com/t-oster/VisiCut/blob/0abe785a30d5d5085dd3b5953b38239b1ff83358/tools/inkscape_extension/visicut_export.py
# Strip SVG to only contain selected elements, convert objects to paths, unlink clones
# Inkscape version: takes care of special cases where the selected objects depend on non-selected ones.
# Examples are linked clones, flowtext limited to a shape and linked flowtext boxes (overflow into the next box).
#
# Inkscape is called with certain "actions" to do the required cleanup
# The idea is similar to http://bazaar.launchpad.net/~nikitakit/inkscape/svg2sif/view/head:/share/extensions/synfig_prepare.py#L181 , but more primitive - there is no need for more complicated preprocessing here
def stripSVG_inkscape(src, dest, elements):
# create temporary file for opening with inkscape.
# delete this file later so that it will disappear from the "recently opened" list.
tmpfile = tempfile.NamedTemporaryFile(delete=False, prefix='temp-visicut-', suffix='.svg')
tmpfile.close()
tmpfile = tmpfile.name
import shutil
shutil.copyfile(src, tmpfile)
# Updated for Inkscape 1.2, released 16 May 2022
# inkscape --export-overwrite --actions=action1;action2...
# (see inkscape --help, inkscape --action-list)
# (for debugging, you can look at the intermediate state by running inkscape --with-gui --actions=... my_filename.svg)
# Note that it is (almost?) impossible to find a sequence that works in all cases.
# Cases to consider:
# - selecting whole groups
# - selecting objects within a group
# - selecting across groups/layers (e.g., enter group, select something, then Shift-click to select things from other layers)
# Difficulties with Inkscape:
# - "invert selection" does not behave as expected in all these cases,
# for example if a group is selected then inverting can select the elements within.
# - Inkscape has a wonderful --export-id commandline switch, but it only works correctly with one ID
# Solution:
actions = []
# - select objects
actions += ["select-by-id:" + ",".join(elements)]
# - convert to path
actions += ["clone-unlink", "object-to-path"]
# - create group of selection
actions += ["selection-group"]
# - set group ID to a known value. Use a pseudo-random value to avoid collisions
target_group_id = "TARGET-GROUP-" + "".join(random.sample(string.ascii_lowercase, 20))
actions += ["object-set-attribute:id," + target_group_id]
# - set export options: use only the target group ID, nothing else
actions += ["export-id-only:true", "export-id:" + target_group_id]
# - export
actions += ["export-do"]
command = [INKSCAPEBIN, tmpfile, "--export-overwrite", "--actions=" + ";".join(actions)]
# to print the resulting commandline:
# print(" ".join(["'" + c + "'" for c in command]), file=sys.stderr)
DEBUG = False
if DEBUG:
# Inkscape sometimes silently ignores wrong verbs, so we need to double-check that everything's right
for action in actions:
aciton_list = [line.split(":")[0] for line in subprocess.check_output([INKSCAPEBIN, "--action-list"], stderr=DEVNULL).split("\n")]
if action not in action_list:
sys.stderr.write("Inkscape does not have the action '{}'. Please report this as a VisiCut bug.".format(action))
inkscape_output = "(not yet run)"
try:
#sys.stderr.write(" ".join(command))
# run inkscape, buffer output
inkscape = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
inkscape_output = inkscape.communicate()[0]
if inkscape.returncode != 0:
sys.stderr.write("Error: cleaning the document with inkscape failed. Something might still be shown in visicut, but it could be incorrect.\nInkscape's output was:\n" + str(inkscape_output))
except:
sys.stderr.write("Error: cleaning the document with inkscape failed. Something might still be shown in visicut, but it could be incorrect. Exception information: \n" + str(sys.exc_info()[0]) + "Inkscape's output was:\n" + str(inkscape_output))
# move output to the intended destination filename
os.rename(tmpfile, dest)
# header
# for debugging purposes you can open the resulting EPS file in Inkscape,
# select all, ungroup multiple times
# --> now you can view the exported lines in inkscape
prefix="""
%!PS-Adobe-3.0 EPSF-3.0
%%LanguageLevel: 2
%%BoundingBox -10000 -10000 10000 10000
%%EndComments
%%BeginSetup
%%EndSetup
%%BeginProlog
% This code (until EndProlog) is from an inkscape-exported EPS, copyright unknown, see cairo-library
save
50 dict begin
/q { gsave } bind def
/Q { grestore } bind def
/cm { 6 array astore concat } bind def
/w { setlinewidth } bind def
/J { setlinecap } bind def
/j { setlinejoin } bind def
/M { setmiterlimit } bind def
/d { setdash } bind def
/m { moveto } bind def
/l { lineto } bind def
/c { curveto } bind def
/h { closepath } bind def
/re { exch dup neg 3 1 roll 5 3 roll moveto 0 rlineto
0 exch rlineto 0 rlineto closepath } bind def
/S { stroke } bind def
/f { fill } bind def
/f* { eofill } bind def
/n { newpath } bind def
/W { clip } bind def
/W* { eoclip } bind def
/BT { } bind def
/ET { } bind def
/pdfmark where { pop globaldict /?pdfmark /exec load put }
{ globaldict begin /?pdfmark /pop load def /pdfmark
/cleartomark load def end } ifelse
/BDC { mark 3 1 roll /BDC pdfmark } bind def
/EMC { mark /EMC pdfmark } bind def
/cairo_store_point { /cairo_point_y exch def /cairo_point_x exch def } def
/Tj { show currentpoint cairo_store_point } bind def
/TJ {
{
dup
type /stringtype eq
{ show } { -0.001 mul 0 cairo_font_matrix dtransform rmoveto } ifelse
} forall
currentpoint cairo_store_point
} bind def
/cairo_selectfont { cairo_font_matrix aload pop pop pop 0 0 6 array astore
cairo_font exch selectfont cairo_point_x cairo_point_y moveto } bind def
/Tf { pop /cairo_font exch def /cairo_font_matrix where
{ pop cairo_selectfont } if } bind def
/Td { matrix translate cairo_font_matrix matrix concatmatrix dup
/cairo_font_matrix exch def dup 4 get exch 5 get cairo_store_point
/cairo_font where { pop cairo_selectfont } if } bind def
/Tm { 2 copy 8 2 roll 6 array astore /cairo_font_matrix exch def
cairo_store_point /cairo_font where { pop cairo_selectfont } if } bind def
/g { setgray } bind def
/rg { setrgbcolor } bind def
/d1 { setcachedevice } bind def
%%EndProlog
%%Page: 1 1
%%BeginPageSetup
%%PageBoundingBox: -10000 -10000 10000 10000
%%EndPageSetup
% This is a severely crippled fucked-up pseudo-postscript for importing in Roland CutStudio
% Do not even try to open it with something else
% FIXME opening with inkscape currently does not show any objects, although it worked some time in the past
% Inkscape header, not used by cutstudio
% Start
q -10000 -10000 10000 10000 rectclip q
0 g
0.286645 w
0 J
0 j
[] 0.0 d
4 M q
% Cutstudio Start
"""
postfix="""
% Cutstudio End
%this is necessary for CutStudio so that the last line isnt skipped:
0 0 m
% Inkscape footer
S Q
Q Q
showpage
%%Trailer
end restore
%%EOF
"""
def EPS2CutstudioEPS(src, dest, mirror=False):
def outputFromStack(stack, n, transformCoordinates=True):
arr=stack[-(n+1):-1]
if transformCoordinates:
arrTransformed=[]
for i in range(n//2):
arrTransformed+=transform(arr[2*i], arr[2*i+1])
return output(arrTransformed+[stack[-1]])
else:
return output(arr+[stack[-1]])
def transform(x, y):
#debug("trafo from: {} {}".format(x, y))
p=numpy.array([[float(x),float(y),1]]).transpose()
multiply = lambda a, b: numpy.matmul(a, b)
# concatenate transformations by multiplying: new = transformation x previousTransformtaion
m=reduce(multiply, scalingStack[::-1])
m=m.transpose()
#debug("with {}".format(m))
pnew = numpy.matmul(m, p)
x=float(pnew[0])
y=float(pnew[1])
#debug("to: {} {}".format(x, y))
return [x, y]
def outputMoveto(x, y):
[xx, yy]=transform(x, y)
return output([str(xx), str(yy), "m"])
def outputLineto(x, y):
[xx, yy]=transform(x, y)
return output([str(xx), str(yy), "l"])
def output(array):
array=list(map(str, array))
output=" ".join(array)
#debug("OUTPUT: "+output)
return output + "\n"
stack=[]
scalingStack=[numpy.identity(3)]
if mirror:
scalingStack.append(numpy.diag([-1, 1, 1]))
lastMoveCoordinates=None
outputStr=prefix
inputFile=open(src)
outputFile=open(dest, "w")
for line in inputFile.readlines():
line=line.strip()
if line.startswith("%"):
# comment line
continue
if line.endswith("re W n"):
continue # ignore clipping rectangle
#debug(line)
for item in line.split(" "):
item=item.strip()
if item=="":
continue
#debug("INPUT: " + item.__repr__())
stack.append(item)
if item=="h": # close path
assert lastMoveCoordinates, "closed path before first moveto"
outputStr += outputLineto(float(lastMoveCoordinates[0]), float(lastMoveCoordinates[1]))
elif item == "c": # bezier curveto
outputStr += outputFromStack(stack, 6)
stack=[]
elif item=="re": # rectangle
x=float(stack[-5])
y=float(stack[-4])
dx=float(stack[-3])
dy=float(stack[-2])
outputStr += outputMoveto(x, y)
outputStr += outputLineto(x+dx, y)
outputStr += outputLineto(x+dx, y+dy)
outputStr += outputLineto(x, y+dy)
outputStr += outputLineto(x, y)
elif item=="cm": # matrix transformation
newTrafo=numpy.array([[float(stack[-7]), float(stack[-6]), 0], [float(stack[-5]), float(stack[-4]), 0], [float(stack[-3]), float(stack[-2]), 1]])
#debug("applying trafo "+str(newTrafo))
scalingStack[-1] = numpy.matmul(scalingStack[-1], newTrafo)
elif item=="q": # save graphics state to stack
scalingStack.append(numpy.identity(3))
elif item=="Q": # pop graphics state from stack
scalingStack.pop()
elif item in ["m", "l"]:
if item=="m": # moveto
lastMoveCoordinates=stack[-3:-1]
elif item=="l": # lineto
pass
outputStr += outputFromStack(stack, 2)
stack=[]
else:
pass # do nothing
outputStr += postfix
outputFile.write(outputStr)
outputFile.close()
inputFile.close()
if os.name=="nt": # windows
INKSCAPEBIN = which("inkscape.exe", True, subdir="Inkscape")
else:
INKSCAPEBIN=which("inkscape", True)
assert os.path.isfile(INKSCAPEBIN), "cannot find inkscape binary " + INKSCAPEBIN
selectedElements=[]
for arg in sys.argv[1:]:
if arg[0] == "-":
if len(arg) >= 5 and arg[0:5] == "--id=":
selectedElements +=[arg[5:]]
else:
filename = arg
if "--selftest" in sys.argv:
filename = "./test-input.svg"
if len(selectedElements)==0:
shutil.copyfile(filename, filename+".filtered.svg")
else:
# only take selected elements
stripSVG_inkscape(src=filename, dest=filename+".filtered.svg", elements=selectedElements)
cmd = [INKSCAPEBIN, "-T", "--export-ignore-filters", "--export-area-drawing", "--export-filename="+filename+".inkscape.eps", filename+".filtered.svg"]
inkscape_eps_file = filename + ".inkscape.eps"
#debug(" ".join(cmd))
assert 0 == subprocess.call(cmd, stderr=DEVNULL), 'EPS conversion failed: command returned error: ' + '"' + '" "'.join(cmd) + '"'
assert os.path.exists(inkscape_eps_file), 'EPS conversion failed: command did not create result file: ' + '"' + '" "'.join(cmd) + '"'
if "--selftest" in sys.argv:
# used for unit-testing: fixed location of output file
destination = "./test-output-actual.cutstudio.eps"
else:
# normally
destination = filename + ".cutstudio.eps"
EPS2CutstudioEPS(inkscape_eps_file, destination, mirror=("--mirror=true" in sys.argv))
if "--selftest" in sys.argv:
# unittest: compare with known reference output
TEST_REFERENCE_FILE = "./test-output-reference.cutstudio.eps"
assert filecmp.cmp(destination, TEST_REFERENCE_FILE), "Test output changed. Please compare " + destination + " and " + TEST_REFERENCE_FILE
print("Selftest successful :-)")
sys.exit(0)
if os.name=="nt":
DETACHED_PROCESS = 8 # start as "daemon"
Popen([which("CutStudio\CutStudio.exe", True), "/import", destination], creationflags=DETACHED_PROCESS, close_fds=True)
else: #check if we have access to "wine"
CUTSTUDIO_C_DRIVE = str(Path.home()) + "/.wine/drive_c/"
CUTSTUDIO_PATH_LINUX_WINE = CUTSTUDIO_C_DRIVE + "Program Files (x86)/CutStudio/CutStudio.exe"
CUTSTUDIO_COMMANDLINE = ["wine", CUTSTUDIO_PATH_LINUX_WINE, "/import", r'C:\cutstudio.eps']
try:
if not which("wine", False):
raise Exception("Cannot find 'wine'")
if not os.path.exists(CUTSTUDIO_PATH_LINUX_WINE):
raise Exception("Cannot find CutStudio in " + CUTSTUDIO_PATH_LINUX_WINE)
shutil.copyfile(destination, CUTSTUDIO_C_DRIVE + "cutstudio.eps")
subprocess.check_call(CUTSTUDIO_COMMANDLINE, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
except Exception as exc:
message("Could not open CutStudio.\nInstead, your file was saved to:\n" + destination + "\n" + \
"Please open that with CutStudio manually. \n\n" + \
"Tip: On Linux, you can use 'wine' to install CutStudio 3.10. Then, the file will be directly opened with CutStudio. \n" + \
" Diagnostic information: \n" + str(exc))
#os.popen("/usr/bin/xdg-open " + filename)
#Popen(["inkscape", filename+".filtered.svg"], stderr=DEVNULL)
#Popen(["inkscape", filename+".cutstudio.eps"])
#os.unlink(filename+".filtered.svg")
#os.unlink(filename)
#os.unlink(filename+".cutstudio.eps")

View File

@ -0,0 +1,21 @@
[
{
"name": "Optimize Sequence: Small Holes First",
"id": "fablabchemnitz.de.optimize_sequence_lasercut_sequence",
"path": "optimize_sequence_lasercut_sequence",
"dependent_extensions": null,
"original_name": "Fix cutting sequence",
"original_id": "L0laapk3.filter.lasercut_sequence",
"license": "GNU AGPL v3",
"license_url": "https://github.com/L0laapk3/inkscape-laser-sequence-extension/blob/master/LICENSE",
"comment": "ported to Inkscape v1 by Mario Voigt",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence",
"fork_url": "https://github.com/L0laapk3/inkscape-laser-sequence-extension",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Optimize+Sequence%3A+Small+Holes+First",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/L0laapk3",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Optimize Sequence: Small Holes First</name>
<id>fablabchemnitz.de.optimize_sequence_lasercut_sequence</id>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Paths - Join/Order"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">optimize_sequence_lasercut_sequence.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
import inkex
import measure
from inkex.paths import Path
from inkex import paths
def getArea(path):
return abs(measure.csparea(paths.CubicSuperPath(path + "z")))
class OptimizeSequenceLasercutSequence(inkex.EffectExtension):
def effect(self):
elements = self.document.xpath('//svg:path',namespaces=inkex.NSS)
for el in elements:
oldpathstring = el.attrib['d']
nodes = Path(oldpathstring).to_arrays()
currentSection = []
sections = [currentSection]
for node in nodes:
command = node.pop(0)
currentSection.append(command + ' ' + ' '.join(list(map(lambda c: ','.join(map(str, c)), node))))
if command.lower() == 'z':
currentSection = []
sections.append(currentSection)
sections = list(map(lambda n: ' '.join(n), filter(lambda n: len(n) > 0, sections)))
if (sections[-1][-2].lower() != 'z'):
nonClosedSection = ' ' + sections.pop()
else:
nonClosedSection = ''
sections = filter(lambda s: s[0].lower() != 'z', sections)
sections = sorted(sections, key=getArea)
newpathstring = "z ".join(sections) + nonClosedSection
el.set('d', newpathstring)
if __name__ == '__main__':
OptimizeSequenceLasercutSequence().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Optimize Sequence: Travel Distances",
"id": "fablabchemnitz.de.optimize_sequence_travel_distance",
"path": "optimize_sequence_travel_distance",
"dependent_extensions": null,
"original_name": "Reorder Paths for Speed",
"original_id": "command.evilmadscientist.eggbot.reorder",
"license": "GNU GPL v3",
"license_url": "https://github.com/evil-mad/EggBot/blob/master/LICENSE",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/optimize_sequence_travel_distance",
"fork_url": "https://github.com/evil-mad/EggBot",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Optimize+Sequence%3A+Travel+Distances",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/evil-mad",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Optimize Sequence: Travel Distances</name>
<id>fablabchemnitz.de.optimize_sequence_travel_distance</id>
<label appearance="header">AxiDraw Plot Optimization Tool</label>
<label>This utility will re-order objects within each layer of your document, to reduce pen-up travel distance and time.</label>
<param name="reordering" gui-text="Group Handling" type="optiongroup" appearance="radio">
<option value="2">Reorder within groups</option>
<option value="1">Preserve groups</option>
<option value="3">Break apart groups</option>
</param>
<param name="preview_rendering" type="bool" gui-text="Preview rendering">false</param>
<label>v 2.6. Copyright 2020, Evil Mad Scientist</label>
<effect needs-live-preview="true">
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Paths - Join/Order" />
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">optimize_sequence_travel_distance.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,744 @@
# plot_utils.py
# Common plotting utilities for EiBotBoard
# https://github.com/evil-mad/plotink
#
# Intended to provide some common interfaces that can be used by
# EggBot, WaterColorBot, AxiDraw, and similar machines.
#
# See below for version information
#
#
# The MIT License (MIT)
#
# Copyright (c) 2019 Windell H. Oskay, Evil Mad Scientist Laboratories
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from math import sqrt
import cspsubdiv
import simplepath
import bezmisc
import ffgeom
def version(): # Version number for this document
return "0.16" # Dated 2019-06-18
__version__ = version()
PX_PER_INCH = 96.0
# This value has changed to 96 px per inch, as of version 0.12 of this library.
# Prior versions used 90 PPI, corresponding the value used in Inkscape < 0.92.
# For use with Inkscape 0.91 (or older), use PX_PER_INCH = 90.0
trivial_svg = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
id="svg15158"
viewBox="0 0 297 210"
height="210mm"
width="297mm">
</svg>
"""
def checkLimits(value, lower_bound, upper_bound):
# Limit a value to within a range.
# Return constrained value with error boolean.
if value > upper_bound:
return upper_bound, True
if value < lower_bound:
return lower_bound, True
return value, False
def checkLimitsTol(value, lower_bound, upper_bound, tolerance):
# Limit a value to within a range.
# Return constrained value with error boolean.
# Allow a range of tolerance where we constrain the value without an error message.
if value > upper_bound:
if value > (upper_bound + tolerance):
return upper_bound, True # Truncate & throw error
else:
return upper_bound, False # Truncate with no error
if value < lower_bound:
if value < (lower_bound - tolerance):
return lower_bound, True # Truncate & throw error
else:
return lower_bound, False # Truncate with no error
return value, False # Return original value without error
def clip_code(x, y, x_min, x_max, y_min, y_max):
# Encode point position with respect to boundary box
code = 0
if x < x_min:
code = 1 # Left
if x > x_max:
code |= 2 # Right
if y < y_min:
code |= 4 # Top
if y > y_max:
code |= 8 # Bottom
return code
def clip_segment(segment, bounds):
"""
Given an input line segment [[x1,y1],[x2,y2]], as well as a
rectangular bounding region [[x_min,y_min],[x_max,y_max]], clip and
keep the part of the segment within the bounding region, using the
CohenSutherland algorithm.
Return a boolean value, "accept", indicating that the output
segment is non-empty, as well as truncated segment,
[[x1',y1'],[x2',y2']], giving the portion of the input line segment
that fits within the bounds.
"""
x1 = segment[0][0]
y1 = segment[0][1]
x2 = segment[1][0]
y2 = segment[1][1]
x_min = bounds[0][0]
y_min = bounds[0][1]
x_max = bounds[1][0]
y_max = bounds[1][1]
while True: # Repeat until return
code_1 = clip_code(x1, y1, x_min, x_max, y_min, y_max)
code_2 = clip_code(x2, y2, x_min, x_max, y_min, y_max)
# Trivial accept:
if code_1 == 0 and code_2 == 0:
return True, segment # Both endpoints are within bounds.
# Trivial reject, if both endpoints are outside, and on the same side:
if code_1 & code_2:
return False, segment # Verify with bitwise AND.
# Otherwise, at least one point is out of bounds; not trivial.
if code_1 != 0:
code = code_1
else:
code = code_2
# Clip at a single boundary; may need to do this up to twice per vertex
if code & 1: # Vertex on LEFT side of bounds:
x = x_min # Find intersection of our segment with x_min
slope = (y2 - y1) / (x2 - x1)
y = slope * (x_min - x1) + y1
elif code & 2: # Vertex on RIGHT side of bounds:
x = x_max # Find intersection of our segment with x_max
slope = (y2 - y1) / (x2 - x1)
y = slope * (x_max - x1) + y1
elif code & 4: # Vertex on TOP side of bounds:
y = y_min # Find intersection of our segment with y_min
slope = (x2 - x1) / (y2 - y1)
x = slope * (y_min - y1) + x1
elif code & 8: # Vertex on BOTTOM side of bounds:
y = y_max # Find intersection of our segment with y_max
slope = (x2 - x1) / (y2 - y1)
x = slope * (y_max - y1) + x1
if code == code_1:
x1 = x
y1 = y
else:
x2 = x
y2 = y
segment = [[x1,y1],[x2,y2]] # Now checking this clipped segment
def constrainLimits(value, lower_bound, upper_bound):
# Limit a value to within a range.
return max(lower_bound, min(upper_bound, value))
def distance(x, y):
"""
Pythagorean theorem
"""
return sqrt(x * x + y * y)
def dotProductXY(input_vector_first, input_vector_second):
temp = input_vector_first[0] * input_vector_second[0] + input_vector_first[1] * input_vector_second[1]
if temp > 1:
return 1
elif temp < -1:
return -1
else:
return temp
def getLength(altself, name, default):
"""
Get the <svg> attribute with name "name" and default value "default"
Parse the attribute into a value and associated units. Then, accept
no units (''), units of pixels ('px'), and units of percentage ('%').
Return value in px.
"""
string_to_parse = altself.document.getroot().get(name)
if string_to_parse:
v, u = parseLengthWithUnits(string_to_parse)
if v is None:
return None
elif u == '' or u == 'px':
return float(v)
elif u == 'in':
return float(v) * PX_PER_INCH
elif u == 'mm':
return float(v) * PX_PER_INCH / 25.4
elif u == 'cm':
return float(v) * PX_PER_INCH / 2.54
elif u == 'Q' or u == 'q':
return float(v) * PX_PER_INCH / (40.0 * 2.54)
elif u == 'pc':
return float(v) * PX_PER_INCH / 6.0
elif u == 'pt':
return float(v) * PX_PER_INCH / 72.0
elif u == '%':
return float(default) * v / 100.0
else:
# Unsupported units
return None
else:
# No width specified; assume the default value
return float(default)
def getLengthInches(altself, name):
"""
Get the <svg> attribute with name "name", and parse it as a length,
into a value and associated units. Return value in inches.
As of version 0.11, units of 'px' or no units ('') are interpreted
as imported px, at a resolution of 96 px per inch, as per the SVG
specification. (Prior versions returned None in this case.)
This allows certain imported SVG files, (imported with units of px)
to plot while they would not previously. However, it may also cause
new scaling issues in some circumstances. Note, for example, that
Adobe Illustrator uses 72 px per inch, and Inkscape used 90 px per
inch prior to version 0.92.
"""
string_to_parse = altself.document.getroot().get(name)
if string_to_parse:
v, u = parseLengthWithUnits(string_to_parse)
if v is None:
return None
elif u == 'in':
return float(v)
elif u == 'mm':
return float(v) / 25.4
elif u == 'cm':
return float(v) / 2.54
elif u == 'Q' or u == 'q':
return float(v) / (40.0 * 2.54)
elif u == 'pc':
return float(v) / 6.0
elif u == 'pt':
return float(v) / 72.0
elif u == '' or u == 'px':
return float(v) / 96.0
else:
# Unsupported units, including '%'
return None
def parseLengthWithUnits(string_to_parse):
"""
Parse an SVG value which may or may not have units attached.
There is a more general routine to consider in scour.py if more
generality is ever needed.
"""
u = 'px'
s = string_to_parse.strip()
if s[-2:] == 'px': # pixels, at a size of PX_PER_INCH per inch
s = s[:-2]
elif s[-2:] == 'in': # inches
s = s[:-2]
u = 'in'
elif s[-2:] == 'mm': # millimeters
s = s[:-2]
u = 'mm'
elif s[-2:] == 'cm': # centimeters
s = s[:-2]
u = 'cm'
elif s[-2:] == 'pt': # points; 1pt = 1/72th of 1in
s = s[:-2]
u = 'pt'
elif s[-2:] == 'pc': # picas; 1pc = 1/6th of 1in
s = s[:-2]
u = 'pc'
elif s[-1:] == 'Q' or s[-1:] == 'q': # quarter-millimeters. 1q = 1/40th of 1cm
s = s[:-1]
u = 'Q'
elif s[-1:] == '%':
u = '%'
s = s[:-1]
try:
v = float(s)
except:
return None, None
return v, u
def unitsToUserUnits(input_string):
"""
Custom replacement for the unittouu routine in inkex.py
Parse the attribute into a value and associated units.
Return value in user units (typically "px").
"""
v, u = parseLengthWithUnits(input_string)
if v is None:
return None
elif u == '' or u == 'px':
return float(v)
elif u == 'in':
return float(v) * PX_PER_INCH
elif u == 'mm':
return float(v) * PX_PER_INCH / 25.4
elif u == 'cm':
return float(v) * PX_PER_INCH / 2.54
elif u == 'Q' or u == 'q':
return float(v) * PX_PER_INCH / (40.0 * 2.54)
elif u == 'pc':
return float(v) * PX_PER_INCH / 6.0
elif u == 'pt':
return float(v) * PX_PER_INCH / 72.0
elif u == '%':
return float(v) / 100.0
else:
# Unsupported units
return None
def subdivideCubicPath(sp, flat, i=1):
"""
Break up a bezier curve into smaller curves, each of which
is approximately a straight line within a given tolerance
(the "smoothness" defined by [flat]).
This is a modified version of cspsubdiv.cspsubdiv(). I rewrote the recursive
call because it caused recursion-depth errors on complicated line segments.
"""
while True:
while True:
if i >= len(sp):
return
p0 = sp[i - 1][1]
p1 = sp[i - 1][2]
p2 = sp[i][0]
p3 = sp[i][1]
b = (p0, p1, p2, p3)
if cspsubdiv.maxdist(b) > flat:
break
i += 1
one, two = bezmisc.beziersplitatt(b, 0.5)
sp[i - 1][2] = one[1]
sp[i][0] = two[2]
p = [one[2], one[3], two[1]]
sp[i:1] = [p]
def max_dist_from_n_points(input):
"""
Like cspsubdiv.maxdist, but it can check for distances of any number of points >= 0.
`input` is an ordered collection of points, each point specified as an x- and y-coordinate.
The first point and the last point define the segment we are finding distances from.
does not mutate `input`
"""
assert len(input) >= 3, "There must be points (other than begin/end) to check."
points = [ffgeom.Point(point[0], point[1]) for point in input]
segment = ffgeom.Segment(points.pop(0), points.pop())
distances = [segment.distanceToPoint(point) for point in points]
return max(distances)
def supersample(vertices, tolerance):
"""
Given a list of vertices, remove some according to the following algorithm.
Suppose that the vertex list consists of points A, B, C, D, E, and so forth, which define segments AB, BC, CD, DE, EF, and so on.
We first test to see if vertex B can be removed, by using perpDistanceToPoint to check whether the distance between B and segment AC is less than tolerance.
If B can be removed, then check to see if the next vertex, C, can be removed. Both B and C can be removed if the both the distance between B and AD is less than Tolerance and the distance between C and AD is less than Tolerance. Continue removing additional vertices, so long as the perpendicular distance between every point removed and the resulting segment is less than tolerance (and the end of the vertex list is not reached).
If B cannot be removed, then move onto vertex C, and perform the same checks, until the end of the vertex list is reached.
"""
if len(vertices) <= 2: # there is nothing to delete
return vertices
start_index = 0 # can't remove first vertex
while start_index < len(vertices) - 2:
end_index = start_index + 2
# test the removal of (start_index, end_index), exclusive until we can't advance end_index
while (max_dist_from_n_points(vertices[start_index:end_index + 1]) < tolerance
and end_index < len(vertices)):
end_index += 1 # try removing the next vertex too
vertices[start_index + 1:end_index - 1] = [] # delete (start_index, end_index), exclusive
start_index += 1
def userUnitToUnits(distance_uu, unit_string):
"""
Custom replacement for the uutounit routine in inkex.py
Parse the attribute into a value and associated units.
Return value in user units (typically "px").
"""
if distance_uu is None: # Couldn't parse the value
return None
elif unit_string == '' or unit_string == 'px':
return float(distance_uu)
elif unit_string == 'in':
return float(distance_uu) / PX_PER_INCH
elif unit_string == 'mm':
return float(distance_uu) / (PX_PER_INCH / 25.4)
elif unit_string == 'cm':
return float(distance_uu) / (PX_PER_INCH / 2.54)
elif unit_string == 'Q' or unit_string == 'q':
return float(distance_uu) / (PX_PER_INCH / (40.0 * 2.54))
elif unit_string == 'pc':
return float(distance_uu) / (PX_PER_INCH / 6.0)
elif unit_string == 'pt':
return float(distance_uu) / (PX_PER_INCH / 72.0)
elif unit_string == '%':
return float(distance_uu) * 100.0
else:
# Unsupported units
return None
def vb_scale(vb, p_a_r, doc_width, doc_height):
""""
Parse SVG viewbox and generate scaling parameters.
Reference documentation: https://www.w3.org/TR/SVG11/coords.html
Inputs:
vb: Contents of SVG viewbox attribute
p_a_r: Contents of SVG preserveAspectRatio attribute
doc_width: Width of SVG document
doc_height: Height of SVG document
Output: sx, sy, ox, oy
Scale parameters (sx,sy) and offset parameters (ox,oy)
"""
if vb is None:
return 1,1,0,0 # No viewbox; return default transform
else:
vb_array = vb.strip().replace(',', ' ').split()
if len(vb_array) < 4:
return 1,1,0,0 # invalid viewbox; return default transform
min_x = float(vb_array[0]) # Viewbox offset: x
min_y = float(vb_array[1]) # Viewbox offset: y
width = float(vb_array[2]) # Viewbox width
height = float(vb_array[3]) # Viewbox height
if width <= 0 or height <= 0:
return 1,1,0,0 # invalid viewbox; return default transform
d_width = float(doc_width)
d_height = float(doc_height)
if d_width <= 0 or d_height <= 0:
return 1,1,0,0 # invalid document size; return default transform
ar_doc = d_height / d_width # Document aspect ratio
ar_vb = height / width # Viewbox aspect ratio
# Default values of the two preserveAspectRatio parameters:
par_align = "xmidymid" # "align" parameter (lowercased)
par_mos = "meet" # "meetOrSlice" parameter
if p_a_r is not None:
par_array = p_a_r.strip().replace(',', ' ').lower().split()
if len(par_array) > 0:
par0 = par_array[0]
if par0 == "defer":
if len(par_array) > 1:
par_align = par_array[1]
if len(par_array) > 2:
par_mos = par_array[2]
else:
par_align = par0
if len(par_array) > 1:
par_mos = par_array[1]
if par_align == "none":
# Scale document to fill page. Do not preserve aspect ratio.
# This is not default behavior, nor what happens if par_align
# is not given; the "none" value must be _explicitly_ specified.
sx = d_width/ width
sy = d_height / height
ox = -min_x
oy = -min_y
return sx,sy,ox,oy
"""
Other than "none", all situations fall into two classes:
1) (ar_doc >= ar_vb AND par_mos == "meet")
or (ar_doc < ar_vb AND par_mos == "slice")
-> In these cases, scale document up until VB fills doc in X.
2) All other cases, i.e.,
(ar_doc < ar_vb AND par_mos == "meet")
or (ar_doc >= ar_vb AND par_mos == "slice")
-> In these cases, scale document up until VB fills doc in Y.
Note in cases where the scaled viewbox exceeds the document
(page) boundaries (all "slice" cases and many "meet" cases where
an offset value is given) that this routine does not perform
any clipping, but subsequent clipping to the page boundary
is appropriate.
Besides "none", there are 9 possible values of par_align:
xminymin xmidymin xmaxymin
xminymid xmidymid xmaxymid
xminymax xmidymax xmaxymax
"""
if (((ar_doc >= ar_vb) and (par_mos == "meet"))
or ((ar_doc < ar_vb) and (par_mos == "slice"))):
# Case 1: Scale document up until VB fills doc in X.
sx = d_width / width
sy = sx # Uniform aspect ratio
ox = -min_x
scaled_vb_height = ar_doc * width
excess_height = scaled_vb_height - height
if par_align in {"xminymin", "xmidymin", "xmaxymin"}:
# Case: Y-Min: Align viewbox to minimum Y of the viewport.
oy = -min_y
# OK: tested with Tall-Meet, Wide-Slice
elif par_align in {"xminymax", "xmidymax", "xmaxymax"}:
# Case: Y-Max: Align viewbox to maximum Y of the viewport.
oy = -min_y + excess_height
# OK: tested with Tall-Meet, Wide-Slice
else: # par_align in {"xminymid", "xmidymid", "xmaxymid"}:
# Default case: Y-Mid: Center viewbox on page in Y
oy = -min_y + excess_height / 2
# OK: Tested with Tall-Meet, Wide-Slice
return sx,sy,ox,oy
else:
# Case 2: Scale document up until VB fills doc in Y.
sy = d_height / height
sx = sy # Uniform aspect ratio
oy = -min_y
scaled_vb_width = height / ar_doc
excess_width = scaled_vb_width - width
if par_align in {"xminymin", "xminymid", "xminymax"}:
# Case: X-Min: Align viewbox to minimum X of the viewport.
ox = -min_x
# OK: Tested with Tall-Slice, Wide-Meet
elif par_align in {"xmaxymin", "xmaxymid", "xmaxymax"}:
# Case: X-Max: Align viewbox to maximum X of the viewport.
ox = -min_x + excess_width
# Need test: Tall-Slice, Wide-Meet
else: # par_align in {"xmidymin", "xmidymid", "xmidymax"}:
# Default case: X-Mid: Center viewbox on page in X
ox = -min_x + excess_width / 2
# OK: Tested with Tall-Slice, Wide-Meet
return sx,sy,ox,oy
return 1,1,0,0 # Catch-all: return default transform
def vInitial_VF_A_Dx(v_final, acceleration, delta_x):
"""
Kinematic calculation: Maximum allowed initial velocity to arrive at distance X
with specified final velocity, and given maximum linear acceleration.
Calculate and return the (real) initial velocity, given an final velocity,
acceleration rate, and distance interval.
Uses the kinematic equation Vi^2 = Vf^2 - 2 a D_x , where
Vf is the final velocity,
a is the acceleration rate,
D_x (delta x) is the distance interval, and
Vi is the initial velocity.
We are looking at the positive root only-- if the argument of the sqrt
is less than zero, return -1, to indicate a failure.
"""
initial_v_squared = (v_final * v_final) - (2 * acceleration * delta_x)
if initial_v_squared > 0:
return sqrt(initial_v_squared)
else:
return -1
def vFinal_Vi_A_Dx(v_initial, acceleration, delta_x):
"""
Kinematic calculation: Final velocity with constant linear acceleration.
Calculate and return the (real) final velocity, given an initial velocity,
acceleration rate, and distance interval.
Uses the kinematic equation Vf^2 = 2 a D_x + Vi^2, where
Vf is the final velocity,
a is the acceleration rate,
D_x (delta x) is the distance interval, and
Vi is the initial velocity.
We are looking at the positive root only-- if the argument of the sqrt
is less than zero, return -1, to indicate a failure.
"""
final_v_squared = (2 * acceleration * delta_x) + (v_initial * v_initial)
if final_v_squared > 0:
return sqrt(final_v_squared)
else:
return -1
def pathdata_first_point(path):
"""
Return the first (X,Y) point from an SVG path data string
Input: A path data string; the text of the 'd' attribute of an SVG path
Output: Two floats in a list representing the x and y coordinates of the first point
"""
# Path origin's default values are used to see if we have
# Written anything to the path_origin variable yet
MaxLength = len(path)
ix = 0
tempString = ''
x_val = ''
y_val = ''
# Check one char at a time
# until we have the moveTo Command
while ix < MaxLength:
if path[ix].upper() == 'M':
break
# Increment until we have M
ix = ix + 1
# Parse path until we reach a digit, decimal point or negative sign
while ix < MaxLength:
if(path[ix].isdigit()) or path[ix] == '.' or path[ix] == '-':
break
ix = ix + 1
# Add digits and decimal points to x_val
# Stop parsing when next character is neither a digit nor a decimal point
while ix < MaxLength:
if (path[ix].isdigit()):
tempString = tempString + path[ix]
x_val = float(tempString )
ix = ix + 1
# If next character is a decimal place, save the decimal and continue parsing
# This allows for paths without leading zeros to be parsed correctly
elif (path[ix] == '.' or path[ix] == '-'):
tempString = tempString + path[ix]
ix = ix + 1
else:
ix = ix + 1
break
# Reset tempString for y coordinate
tempString = ''
# Parse path until we reach a digit or decimal point
while ix < MaxLength:
if(path[ix].isdigit()) or path[ix] == '.' or path[ix] == '-':
break
ix = ix + 1
# Add digits and decimal points to y_val
# Stop parsin when next character is neither a digit nor a decimal point
while ix < MaxLength:
if (path[ix].isdigit() ):
tempString = tempString + path[ix]
y_val = float(tempString)
ix = ix + 1
# If next character is a decimal place, save the decimal and continue parsing
# This allows for paths without leading zeros to be parsed correctly
elif (path[ix] == '.' or path[ix] == '-'):
tempString = tempString + path[ix]
ix = ix + 1
else:
ix = ix + 1
break
return [x_val,y_val]
def pathdata_last_point(path):
"""
Return the last (X,Y) point from an SVG path data string
Input: A path data string; the text of the 'd' attribute of an SVG path
Output: Two floats in a list representing the x and y coordinates of the last point
"""
command, params = simplepath.parsePath(path)[-1] # parsePath splits path into segments
if command.upper() == 'Z':
return pathdata_first_point(path) # Trivial case
"""
Otherwise: The last command should be in the set 'MLCQA'
- All commands converted to absolute by parsePath.
- Can ignore Z (case handled)
- Can ignore H,V, since those are converted to L by parsePath.
- Can ignore S, converted to C by parsePath.
- Can ignore T, converted to Q by parsePath.
MLCQA: Commands all ending in (X,Y) pair.
"""
x_val = params[-2] # Second to last parameter given
y_val = params[-1] # Last parameter given
return [x_val,y_val]

View File

@ -0,0 +1,211 @@
"""
simplepath.py
functions for digesting paths into a simple list structure
Copyright (C) 2005 Aaron Spike, aaron@ekips.org
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""
import re, math
def lexPath(d):
"""
returns and iterator that breaks path data
identifies command and parameter tokens
"""
offset = 0
length = len(d)
delim = re.compile(r'[ \t\r\n,]+')
command = re.compile(r'[MLHVCSQTAZmlhvcsqtaz]')
parameter = re.compile(r'(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)')
while 1:
m = delim.match(d, offset)
if m:
offset = m.end()
if offset >= length:
break
m = command.match(d, offset)
if m:
yield [d[offset:m.end()], True]
offset = m.end()
continue
m = parameter.match(d, offset)
if m:
yield [d[offset:m.end()], False]
offset = m.end()
continue
#TODO: create new exception
raise Exception('Invalid path data!')
'''
pathdefs = {commandfamily:
[
implicitnext,
#params,
[casts,cast,cast],
[coord type,x,y,0]
]}
'''
pathdefs = {
'M':['L', 2, [float, float], ['x','y']],
'L':['L', 2, [float, float], ['x','y']],
'H':['H', 1, [float], ['x']],
'V':['V', 1, [float], ['y']],
'C':['C', 6, [float, float, float, float, float, float], ['x','y','x','y','x','y']],
'S':['S', 4, [float, float, float, float], ['x','y','x','y']],
'Q':['Q', 4, [float, float, float, float], ['x','y','x','y']],
'T':['T', 2, [float, float], ['x','y']],
'A':['A', 7, [float, float, float, int, int, float, float], ['r','r','a',0,'s','x','y']],
'Z':['L', 0, [], []]
}
def parsePath(d):
"""
Parse SVG path and return an array of segments.
Removes all shorthand notation.
Converts coordinates to absolute.
"""
retval = []
lexer = lexPath(d)
pen = (0.0,0.0)
subPathStart = pen
lastControl = pen
lastCommand = ''
while 1:
try:
token, isCommand = next(lexer)
except StopIteration:
break
params = []
needParam = True
if isCommand:
if not lastCommand and token.upper() != 'M':
raise Exception('Invalid path, must begin with moveto.')
else:
command = token
else:
#command was omited
#use last command's implicit next command
needParam = False
if lastCommand:
if lastCommand.isupper():
command = pathdefs[lastCommand][0]
else:
command = pathdefs[lastCommand.upper()][0].lower()
else:
raise Exception('Invalid path, no initial command.')
numParams = pathdefs[command.upper()][1]
while numParams > 0:
if needParam:
try:
token, isCommand = next(lexer)
if isCommand:
raise Exception('Invalid number of parameters')
except StopIteration:
raise Exception('Unexpected end of path')
cast = pathdefs[command.upper()][2][-numParams]
param = cast(token)
if command.islower():
if pathdefs[command.upper()][3][-numParams]=='x':
param += pen[0]
elif pathdefs[command.upper()][3][-numParams]=='y':
param += pen[1]
params.append(param)
needParam = True
numParams -= 1
#segment is now absolute so
outputCommand = command.upper()
#Flesh out shortcut notation
if outputCommand in ('H','V'):
if outputCommand == 'H':
params.append(pen[1])
if outputCommand == 'V':
params.insert(0,pen[0])
outputCommand = 'L'
if outputCommand in ('S','T'):
params.insert(0,pen[1]+(pen[1]-lastControl[1]))
params.insert(0,pen[0]+(pen[0]-lastControl[0]))
if outputCommand == 'S':
outputCommand = 'C'
if outputCommand == 'T':
outputCommand = 'Q'
#current values become "last" values
if outputCommand == 'M':
subPathStart = tuple(params[0:2])
pen = subPathStart
if outputCommand == 'Z':
pen = subPathStart
else:
pen = tuple(params[-2:])
if outputCommand in ('Q','C'):
lastControl = tuple(params[-4:-2])
else:
lastControl = pen
lastCommand = command
retval.append([outputCommand,params])
return retval
def formatPath(a):
"""Format SVG path data from an array"""
return "".join([cmd + " ".join([str(p) for p in params]) for cmd, params in a])
def translatePath(p, x, y):
for cmd,params in p:
defs = pathdefs[cmd]
for i in range(defs[1]):
if defs[3][i] == 'x':
params[i] += x
elif defs[3][i] == 'y':
params[i] += y
def scalePath(p, x, y):
for cmd,params in p:
defs = pathdefs[cmd]
for i in range(defs[1]):
if defs[3][i] == 'x':
params[i] *= x
elif defs[3][i] == 'y':
params[i] *= y
elif defs[3][i] == 'r': # radius parameter
params[i] *= x
elif defs[3][i] == 's': # sweep-flag parameter
if x*y < 0:
params[i] = 1 - params[i]
elif defs[3][i] == 'a': # x-axis-rotation angle
if y < 0:
params[i] = - params[i]
def rotatePath(p, a, cx = 0, cy = 0):
if a == 0:
return p
for cmd,params in p:
defs = pathdefs[cmd]
for i in range(defs[1]):
if defs[3][i] == 'x':
x = params[i] - cx
y = params[i + 1] - cy
r = math.sqrt((x**2) + (y**2))
if r != 0:
theta = math.atan2(y, x) + a
params[i] = (r * math.cos(theta)) + cx
params[i + 1] = (r * math.sin(theta)) + cy
# vim: expandtab shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=99

View File

@ -0,0 +1,261 @@
'''
Copyright (C) 2006 Jean-Francois Barraud, barraud@math.univ-lille1.fr
Copyright (C) 2010 Alvin Penner, penner@vaxxine.com
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
barraud@math.univ-lille1.fr
This code defines several functions to make handling of transform
attribute easier.
'''
import inkex, cubicsuperpath, bezmisc, simplestyle
import copy, math, re
def parseTransform(transf,mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]):
if transf=="" or transf==None:
return(mat)
stransf = transf.strip()
result=re.match(r"(translate|scale|rotate|skewX|skewY|matrix)\s*\(([^)]*)\)\s*,?",stransf)
#-- translate --
if result.group(1)=="translate":
args=result.group(2).replace(',',' ').split()
dx=float(args[0])
if len(args)==1:
dy=0.0
else:
dy=float(args[1])
matrix=[[1,0,dx],[0,1,dy]]
#-- scale --
if result.group(1)=="scale":
args=result.group(2).replace(',',' ').split()
sx=float(args[0])
if len(args)==1:
sy=sx
else:
sy=float(args[1])
matrix=[[sx,0,0],[0,sy,0]]
#-- rotate --
if result.group(1)=="rotate":
args=result.group(2).replace(',',' ').split()
a=float(args[0])*math.pi/180
if len(args)==1:
cx,cy=(0.0,0.0)
else:
cx,cy=map(float,args[1:])
matrix=[[math.cos(a),-math.sin(a),cx],[math.sin(a),math.cos(a),cy]]
matrix=composeTransform(matrix,[[1,0,-cx],[0,1,-cy]])
#-- skewX --
if result.group(1)=="skewX":
a=float(result.group(2))*math.pi/180
matrix=[[1,math.tan(a),0],[0,1,0]]
#-- skewY --
if result.group(1)=="skewY":
a=float(result.group(2))*math.pi/180
matrix=[[1,0,0],[math.tan(a),1,0]]
#-- matrix --
if result.group(1)=="matrix":
a11,a21,a12,a22,v1,v2=result.group(2).replace(',',' ').split()
matrix=[[float(a11),float(a12),float(v1)], [float(a21),float(a22),float(v2)]]
matrix=composeTransform(mat,matrix)
if result.end() < len(stransf):
return(parseTransform(stransf[result.end():], matrix))
else:
return matrix
def formatTransform(mat):
return ("matrix(%f,%f,%f,%f,%f,%f)" % (mat[0][0], mat[1][0], mat[0][1], mat[1][1], mat[0][2], mat[1][2]))
def invertTransform(mat):
det = mat[0][0]*mat[1][1] - mat[0][1]*mat[1][0]
if det !=0: # det is 0 only in case of 0 scaling
# invert the rotation/scaling part
a11 = mat[1][1]/det
a12 = -mat[0][1]/det
a21 = -mat[1][0]/det
a22 = mat[0][0]/det
# invert the translational part
a13 = -(a11*mat[0][2] + a12*mat[1][2])
a23 = -(a21*mat[0][2] + a22*mat[1][2])
return [[a11,a12,a13],[a21,a22,a23]]
else:
return[[0,0,-mat[0][2]],[0,0,-mat[1][2]]]
def composeTransform(M1,M2):
a11 = M1[0][0]*M2[0][0] + M1[0][1]*M2[1][0]
a12 = M1[0][0]*M2[0][1] + M1[0][1]*M2[1][1]
a21 = M1[1][0]*M2[0][0] + M1[1][1]*M2[1][0]
a22 = M1[1][0]*M2[0][1] + M1[1][1]*M2[1][1]
v1 = M1[0][0]*M2[0][2] + M1[0][1]*M2[1][2] + M1[0][2]
v2 = M1[1][0]*M2[0][2] + M1[1][1]*M2[1][2] + M1[1][2]
return [[a11,a12,v1],[a21,a22,v2]]
def composeParents(node, mat):
trans = node.get('transform')
if trans:
mat = composeTransform(parseTransform(trans), mat)
if node.getparent().tag == inkex.addNS('g','svg'):
mat = composeParents(node.getparent(), mat)
return mat
def applyTransformToNode(mat,node):
m=parseTransform(node.get("transform"))
newtransf=formatTransform(composeTransform(mat,m))
node.set("transform", newtransf)
def applyTransformToPoint(mat,pt):
x = mat[0][0]*pt[0] + mat[0][1]*pt[1] + mat[0][2]
y = mat[1][0]*pt[0] + mat[1][1]*pt[1] + mat[1][2]
pt[0]=x
pt[1]=y
def applyTransformToPath(mat,path):
for comp in path:
for ctl in comp:
for pt in ctl:
applyTransformToPoint(mat,pt)
def fuseTransform(node):
if node.get('d')==None:
#FIXME: how do you raise errors?
raise AssertionError('can not fuse "transform" of elements that have no "d" attribute')
t = node.get("transform")
if t == None:
return
m = parseTransform(t)
d = node.get('d')
p = cubicsuperpath.parsePath(d)
applyTransformToPath(m,p)
node.set('d', cubicsuperpath.formatPath(p))
del node.attrib["transform"]
####################################################################
##-- Some functions to compute a rough bbox of a given list of objects.
##-- this should be shipped out in an separate file...
def boxunion(b1,b2):
if b1 is None:
return b2
elif b2 is None:
return b1
else:
return((min(b1[0],b2[0]), max(b1[1],b2[1]), min(b1[2],b2[2]), max(b1[3],b2[3])))
def roughBBox(path):
xmin,xMax,ymin,yMax = path[0][0][0][0],path[0][0][0][0],path[0][0][0][1],path[0][0][0][1]
for pathcomp in path:
for ctl in pathcomp:
for pt in ctl:
xmin = min(xmin,pt[0])
xMax = max(xMax,pt[0])
ymin = min(ymin,pt[1])
yMax = max(yMax,pt[1])
return xmin,xMax,ymin,yMax
def refinedBBox(path):
xmin,xMax,ymin,yMax = path[0][0][1][0],path[0][0][1][0],path[0][0][1][1],path[0][0][1][1]
for pathcomp in path:
for i in range(1, len(pathcomp)):
cmin, cmax = cubicExtrema(pathcomp[i-1][1][0], pathcomp[i-1][2][0], pathcomp[i][0][0], pathcomp[i][1][0])
xmin = min(xmin, cmin)
xMax = max(xMax, cmax)
cmin, cmax = cubicExtrema(pathcomp[i-1][1][1], pathcomp[i-1][2][1], pathcomp[i][0][1], pathcomp[i][1][1])
ymin = min(ymin, cmin)
yMax = max(yMax, cmax)
return xmin,xMax,ymin,yMax
def cubicExtrema(y0, y1, y2, y3):
cmin = min(y0, y3)
cmax = max(y0, y3)
d1 = y1 - y0
d2 = y2 - y1
d3 = y3 - y2
if (d1 - 2*d2 + d3):
if (d2*d2 > d1*d3):
t = (d1 - d2 + math.sqrt(d2*d2 - d1*d3))/(d1 - 2*d2 + d3)
if (t > 0) and (t < 1):
y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t
cmin = min(cmin, y)
cmax = max(cmax, y)
t = (d1 - d2 - math.sqrt(d2*d2 - d1*d3))/(d1 - 2*d2 + d3)
if (t > 0) and (t < 1):
y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t
cmin = min(cmin, y)
cmax = max(cmax, y)
elif (d3 - d1):
t = -d1/(d3 - d1)
if (t > 0) and (t < 1):
y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t
cmin = min(cmin, y)
cmax = max(cmax, y)
return cmin, cmax
def computeBBox(aList,mat=[[1,0,0],[0,1,0]]):
bbox=None
for node in aList:
m = parseTransform(node.get('transform'))
m = composeTransform(mat,m)
#TODO: text not supported!
d = None
if node.get("d"):
d = node.get('d')
elif node.get('points'):
d = 'M' + node.get('points')
elif node.tag in [ inkex.addNS('rect','svg'), 'rect', inkex.addNS('image','svg'), 'image' ]:
d = 'M' + node.get('x', '0') + ',' + node.get('y', '0') + \
'h' + node.get('width') + 'v' + node.get('height') + \
'h-' + node.get('width')
elif node.tag in [ inkex.addNS('line','svg'), 'line' ]:
d = 'M' + node.get('x1') + ',' + node.get('y1') + \
' ' + node.get('x2') + ',' + node.get('y2')
elif node.tag in [ inkex.addNS('circle','svg'), 'circle', \
inkex.addNS('ellipse','svg'), 'ellipse' ]:
rx = node.get('r')
if rx is not None:
ry = rx
else:
rx = node.get('rx')
ry = node.get('ry')
cx = float(node.get('cx', '0'))
cy = float(node.get('cy', '0'))
x1 = cx - float(rx)
x2 = cx + float(rx)
d = 'M %f %f ' % (x1, cy) + \
'A' + rx + ',' + ry + ' 0 1 0 %f,%f' % (x2, cy) + \
'A' + rx + ',' + ry + ' 0 1 0 %f,%f' % (x1, cy)
if d is not None:
p = cubicsuperpath.parsePath(d)
applyTransformToPath(m,p)
bbox=boxunion(refinedBBox(p),bbox)
elif node.tag == inkex.addNS('use','svg') or node.tag=='use':
refid=node.get(inkex.addNS('href','xlink'))
path = '//*[@id="%s"]' % refid[1:]
refnode = node.xpath(path)
bbox=boxunion(computeBBox(refnode,m),bbox)
bbox=boxunion(computeBBox(node,m),bbox)
return bbox
def computePointInNode(pt, node, mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]):
if node.getparent() is not None:
applyTransformToPoint(invertTransform(composeParents(node, mat)), pt)
return pt
# vim: expandtab shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=99

View File

@ -0,0 +1,21 @@
[
{
"name": "Purge Duplicate Path Nodes",
"id": "fablabchemnitz.de.purge_duplicate_path_nodes",
"path": "purge_duplicate_path_nodes",
"dependent_extensions": null,
"original_name": "Remove duplicate nodes",
"original_id": "EllenWasbo.cutlings.RemoveDuplicateNodes",
"license": "GNU GPL v2",
"license_url": "https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatenodes/-/blob/master/removeDuplicateNodes.py",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/purge_duplicate_path_nodes",
"fork_url": "https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatenodes",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Purge+Duplicate+Path+Nodes",
"inkscape_gallery_url": null,
"main_authors": [
"gitlab.com/EllenWasbo",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Purge Duplicate Path Nodes</name>
<id>fablabchemnitz.de.purge_duplicate_path_nodes</id>
<param type="notebook" name="tab">
<page name="options" gui-text="Options">
<label>Remove duplicate nodes from selected paths.</label>
<param name="minUse" type="bool" gui-text="Interpolate nodes of segments with total length less than specified length">false</param>
<param name="minlength" indent="4" type="float" precision="2" min="0" max="9999" gui-text="Minimum segment length">0.01</param>
<param name="joinEnd" type="bool" gui-text="Close subpaths where start and end node have a distance of less than">false</param>
<param name="maxdist" indent="4" type="float" precision="2" min="0" max="9999" gui-text="Limit">0.01</param>
<param name="joinEndSub" type="bool" gui-text="Join end nodes of separate subpaths where distance less than">false</param>
<param name="maxdist2" indent="4" type="float" precision="2" min="0" max="9999" gui-text="Limit">0.01</param>
<param name="allowReverse" indent="4" type="bool" gui-text="Allow reversing direction of subpaths">true</param>
<param name="optionJoin" indent="4" type="optiongroup" appearance="combo" gui-text="Join subpaths by">
<option value="1">interpolating nodes</option>
<option value="2">adding straight line segment</option>
</param>
<label>Unit as defined in document (File-&gt;Document Properties).</label>
</page>
<page name="help" gui-text="Information">
<label xml:space="preserve">
Originally created to clean up paths for cutters/plotters removing excessive nodes or small gaps.
Remove duplicate nodes (with exact same coordinates will always be performed.
To join paths, make sure that the paths to consider are already combined (subpath of the same path).
To combine paths, select them and press Ctrl+K.</label>
<label appearance="header">For more information</label>
<label appearance="url">https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatenodes</label>
</page>
</param>
<effect>
<object-type>path</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Paths - Cut/Intersect/Purge" />
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">purge_duplicate_path_nodes.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,524 @@
#!/usr/bin/env python3
# coding=utf-8
#
# Copyright (C) 2020 Ellen Wasboe, ellen@wasbo.net
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor,
# Boston, MA 02110-1301, USA.
"""
Remove duplicate nodes or interpolate nodes with distance less than specified.
Optionally:
join start and end node of each subpath if distance < threshold
join separate subpaths if end nodes closer than threshold
Joining subpaths can be done either by interpolating or straight line segment.
"""
import inkex
from inkex import bezier, CubicSuperPath
import numpy as np
from tkinter import messagebox
def join_search(xdiff, ydiff, limDist, idsIncluded):
"""Search for loose ends to join if within limDist."""
joinFlag = False
idJoin = -1
dist = np.sqrt(np.add(np.power(xdiff, 2), np.power(ydiff, 2)))
minDist = np.amin(dist)
if minDist < limDist:
joinFlag = True
idMins = np.where(dist == minDist)
idMin = idMins[0]
idJoin = idsIncluded[idMin[0]]
return [joinFlag, idJoin]
def reverse_sub(subPath):
"""Reverse sub path."""
subPath = subPath[::-1]
for i, s in enumerate(subPath):
subPath[i] = s[::-1]
return subPath
def join_sub(sub1, sub2, interpolate_or_line):
"""Join line segments by interpolation or straight line segment."""
if interpolate_or_line == "1":
# interpolate end nodes
p1 = sub1[-1][-1]
p2 = sub2[0][0]
joinNode = [0.5 * (p1[0] + p2[0]), 0.5 * (p1[1] + p2[1])]
# remove end/start + input join
sub1[-1][1] = joinNode
sub1[-1][2] = sub2[0][2]
sub2.pop(0)
newsub = sub1 + sub2
return newsub
def remove_duplicate_nodes(
elem, minlength, maxdist, maxdist2, allowReverse, optionJoin
):
pp = elem.path.to_absolute()
# register which subpaths are closed - to reset closing after
# info are lost in to_superpath
dList = str(pp).upper().split(" M")
closed = []
li = 0
for sub in dList:
if dList[li].find("Z") > -1:
closed.append(" Z ")
else:
closed.append("")
li += 1
new = []
nSub = len(closed)
xStart = np.zeros(nSub) # x start - prepare for joining subpaths
yStart = np.copy(xStart)
xEnd = np.copy(xStart)
yEnd = np.copy(xStart)
s = 0
for sub in pp.to_superpath():
new.append([sub[0]])
if maxdist2 > -1:
xStart[s] = sub[0][0][0]
yStart[s] = sub[0][0][1]
xEnd[s] = sub[-1][-1][0]
yEnd[s] = sub[-1][-1][1]
# remove segment if segment length is less than minimum set,
# keep position
i = 1
lastCombined = False
while i <= len(sub) - 1:
length = bezier.cspseglength(new[-1][-1], sub[i]) # curve length
if length >= minlength:
new[-1].append(sub[i]) # add as is
lastCombined = False
else:
# keep including segments until total length > minlength
summedlength = length
proceed = True
e = 0 # extra segments
finishedAdding = False
while proceed and i + e + 1 <= len(sub) - 1:
nextlength = bezier.cspseglength(sub[i + e], sub[i + e + 1])
if nextlength >= minlength: # not include the next segment
proceed = False
if lastCombined == False and i > 1:
# i.e.small group between long segments,
# average over the group, first node already added
# change position to average
new[-1][-1][1][0] = 0.5 * (
new[-1][-1][1][0] + sub[i + e][1][0]
)
new[-1][-1][1][1] = 0.5 * (
new[-1][-1][1][1] + sub[i + e][1][1]
)
# change last cp to that of the last node in group
new[-1][-1][2] = sub[i + e][2]
finishedAdding = True
else:
new[-1].append(sub[i]) # add as is
if e > 0:
# end of group with many segments - average over
# all but last node (which is added separately)
# change position to average first/last
new[-1][-1][1][0] = 0.5 * (
new[-1][-1][1][0] + sub[i + e - 1][1][0]
)
new[-1][-1][1][1] = 0.5 * (
new[-1][-1][1][1] + sub[i + e - 1][1][1]
)
# change last cp to that of the last node in group
new[-1][-1][2] = sub[i + e - 1][2]
new[-1].append(sub[i + e]) # add as is
finishedAdding = True
lastCombined = True
else:
summedlength = summedlength + nextlength
if summedlength >= minlength:
proceed = False
e = e + 1
if finishedAdding == False:
if i == 1:
# if first segment keep position of first node,
# direction of last in group
new[-1][-1][2][0] = sub[i + e][2][0]
new[-1][-1][2][1] = sub[i + e][2][1]
elif i + e == len(sub) - 1:
# if last segment included keep position of last node,
# direction of previous
new[-1].append(sub[i]) # add first node in group
if e > 0:
new[-1].append(sub[i + e]) # add last node
# get first cp from i+1
new[-1][-1][0] = sub[i + 1][0]
else:
# average position over first/last in group and keep direction (controlpoint) of first/last node
# group within sequence of many close nodes - add new without averaging on previous
new[-1].append(sub[i]) # add first node in group
# change position to average
new[-1][-1][1][0] = 0.5 * (new[-1][-1][1][0] + sub[i + e][1][0])
new[-1][-1][1][1] = 0.5 * (new[-1][-1][1][1] + sub[i + e][1][1])
# change last cp to that of the last node in group
new[-1][-1][2] = sub[i + e][2]
i = i + e
i += 1
if closed[s] == " Z ":
# if new[-1][-1][1]==new[-1][-2][1]:#not always precise
new[-1].pop(-1)
# for some reason tosuperpath adds an extra node for closed paths
# close each subpath where start/end node is closer than maxdist set
# (if not already closed)
if maxdist > -1:
if closed[s] == "": # ignore already closed paths
# calculate distance between first and last node,
# if <= maxdist set closed[i] to " Z "
# last=new[-1][-1]
length = bezier.cspseglength(new[-1][-1], sub[0])
if length < maxdist:
newStartEnd = [
0.5 * (new[-1][-1][-1][0] + new[-1][0][0][0]),
0.5 * (new[-1][-1][-1][1] + new[-1][0][0][1]),
]
new[-1][0][0] = newStartEnd
new[-1][0][1] = newStartEnd
new[-1][-1][1] = newStartEnd
new[-1][-1][2] = newStartEnd
closed[s] = " Z "
s += 1
# join different subpaths?
closed = np.array(closed)
openPaths = np.where(closed == "")
closedPaths = np.where(closed == " Z ")
if maxdist2 > -1 and openPaths[0].size > 1:
# calculate distance between end nodes of the subpaths.
# If distance < maxdist2 found - join
joinStartToEnd = np.ones(nSub, dtype=bool)
joinEndToStart = np.copy(joinStartToEnd)
joinEndTo = np.full(nSub, -1)
# set higher than maxdist2 to avoid join to closedPaths
joinEndTo[closedPaths] = 2 * maxdist2
joinStartTo = np.copy(joinEndTo)
# join end node of current subpath to startnode of any other
# or start node of current to end node of other (no reverse)
s = 0
while s < nSub:
# end of current to start of other
if joinEndTo[s] == -1:
# find available start nodes
idsTest = np.where(joinStartTo == -1)
# avoid join to self
id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s))
if id2Test.size > 0:
# calculate distances in x/y direction
diff_x = np.subtract(xStart[id2Test], xEnd[s])
diff_y = np.subtract(yStart[id2Test], yEnd[s])
# find shortest distance if less than minimum
res = join_search(diff_x, diff_y, maxdist2, id2Test)
if res[0] == True:
# if match found flag end of this with id of other and flag start of match to end of this
joinEndTo[s] = res[1]
joinStartTo[res[1]] = s
# start of current to end of other
if joinStartTo[s] == -1:
idsTest = np.where(joinEndTo == -1)
id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s))
if id2Test.size > 0:
diff_x = np.subtract(xEnd[id2Test], xStart[s])
diff_y = np.subtract(yEnd[id2Test], yStart[s])
res = join_search(diff_x, diff_y, maxdist2, id2Test)
if res[0] == True:
joinStartTo[s] = res[1]
joinEndTo[res[1]] = s
if allowReverse == True:
# start to start - if match reverse (reverseSub[s]=True)
if joinStartTo[s] == -1:
idsTest = np.where(joinStartTo == -1)
id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s))
if id2Test.size > 0:
diff_x = np.subtract(xStart[id2Test], xStart[s])
diff_y = np.subtract(yStart[id2Test], yStart[s])
res = join_search(diff_x, diff_y, maxdist2, id2Test)
if res[0] == True:
jID = res[1]
joinStartTo[s] = jID
joinStartTo[jID] = s
joinStartToEnd[s] = False # false means reverse
joinStartToEnd[jID] = False
# end to end
if joinEndTo[s] == -1:
idsTest = np.where(joinEndTo == -1)
id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s))
if id2Test.size > 0:
diff_x = np.subtract(xEnd[id2Test], xEnd[s])
diff_y = np.subtract(yEnd[id2Test], yEnd[s])
res = join_search(diff_x, diff_y, maxdist2, id2Test)
if res[0] == True:
jID = res[1]
joinEndTo[s] = jID
joinEndTo[jID] = s
joinEndToStart[s] = False
joinEndToStart[jID] = False
s += 1
old = new
new = []
s = 0
movedTo = np.arange(nSub)
newClosed = []
# avoid joining to other paths if already closed
joinEndTo[closedPaths] = -1
joinStartTo[closedPaths] = -1
for s in range(0, nSub):
if movedTo[s] == s: # not joined yet
if joinEndTo[s] > -1 or joinStartTo[s] > -1:
# any join scheduled
thisSub = []
closedThis = ""
if joinEndTo[s] > -1:
# join one by one until -1 or back to s (closed)
jID = joinEndTo[s]
sub1 = old[s]
sub2 = old[jID]
rev = True if joinEndToStart[s] == False else False
sub2 = reverse_sub(sub2) if rev == True else sub2
thisSub = join_sub(sub1, sub2, optionJoin)
movedTo[jID] = s
prev = s
# continue if sub2 joined to more
if joinEndTo[jID] > -1 and joinStartTo[jID] > -1:
# already joined so both joined if continue
proceed = 1
while proceed == 1:
nID = (
joinEndTo[jID]
if joinEndTo[jID] != prev
else joinStartTo[jID]
)
if movedTo[nID] == s:
closedThis = " Z "
proceed = 0
else:
sub2 = old[nID]
if (
nID == joinEndTo[jID]
and joinStartTo[nID] == jID
) or (
nID == joinStartTo[jID]
and joinEndTo[nID] == jID
):
pass
else:
rev = not rev
sub2 = reverse_sub(sub2) if rev == True else sub2
thisSub = join_sub(thisSub, sub2, optionJoin)
movedTo[nID] = s
if joinEndTo[nID] > -1 and joinStartTo[nID] > -1:
prev = jID
jID = nID
else:
proceed = 0
if joinStartTo[s] > -1 and closedThis == "":
jID = joinStartTo[s]
sub1 = old[jID]
rev = True if joinStartToEnd[s] == False else False
sub1 = reverse_sub(sub1) if rev == True else sub1
sub2 = thisSub if len(thisSub) > 0 else old[s]
thisSub = join_sub(sub1, sub2, optionJoin)
movedTo[jID] = s
prev = s
# continue if sub1 joined to more
if joinEndTo[jID] > -1 and joinStartTo[jID] > -1:
proceed = 1
while proceed == 1:
nID = (
joinStartTo[jID]
if joinStartTo[jID] != prev
else joinEndTo[jID]
)
if movedTo[nID] == s:
closedThis = " Z "
proceed = 0
else:
sub1 = old[nID]
if (
nID == joinEndTo[jID]
and joinStartTo[nID] == jID
) or (
nID == joinStartTo[jID]
and joinEndTo[nID] == jID
):
pass
else:
rev = not rev
sub1 = reverse_sub(sub1) if rev == True else sub1
thisSub = join_sub(sub1, thisSub, optionJoin)
movedTo[nID] = s
if joinEndTo[nID] > -1 and joinStartTo[nID] > -1:
prev = jID
jID = nID
else:
proceed = 0
# close the new subpath if start/end node is closer than maxdist
# (should be handled above, but is not so this was a quick fix)
if closedThis == " Z " and optionJoin == "1":
newStartEnd = [
0.5 * (thisSub[-1][-1][0] + thisSub[0][0][0]),
0.5 * (thisSub[-1][-1][1] + thisSub[0][0][1]),
]
thisSub[0][0] = newStartEnd
thisSub[0][1] = newStartEnd
thisSub[-1][1] = newStartEnd
thisSub[-1][2] = newStartEnd
new.append(thisSub)
newClosed.append(closedThis)
else:
new.append(old[s])
newClosed.append(closed[s])
closed = newClosed
nEmpty = new.count([])
if nEmpty > 0:
for i in range(nEmpty):
idx_empty = new.index([])
new.pop(idx_empty)
closed = np.delete(closed, idx_empty)
return (new, closed)
class RemoveDuplicateNodes(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument("--tab", default="options")
pars.add_argument("--minlength", default="0")
pars.add_argument("--minUse", type=inkex.Boolean, default=False)
pars.add_argument("--maxdist", default="0")
pars.add_argument("--joinEnd", type=inkex.Boolean, default=False)
pars.add_argument("--maxdist2", default="0")
pars.add_argument("--joinEndSub", type=inkex.Boolean, default=False)
pars.add_argument("--allowReverse", type=inkex.Boolean, default=True)
pars.add_argument("--optionJoin", default="1")
"""Remove duplicate nodes"""
def effect(self):
if not self.svg.selected:
raise inkex.AbortExtension("Please select an object.")
minlength = float(self.options.minlength)
maxdist = float(self.options.maxdist)
maxdist2 = float(self.options.maxdist2)
if self.options.minUse is False:
minlength = 0
if self.options.joinEnd is False:
maxdist = -1
if self.options.joinEndSub is False:
maxdist2 = -1
nFailed = 0
nInkEffect = 0
for id, elem in self.svg.selection.id_dict().items():
thisIsPath = True
if elem.get("d") is None:
thisIsPath = False
nFailed += 1
if elem.get("inkscape:path-effect") is not None:
thisIsPath = False
nInkEffect += 1
if thisIsPath:
new, closed = remove_duplicate_nodes(
elem,
minlength,
maxdist,
maxdist2,
self.options.allowReverse,
self.options.optionJoin,
)
elem.path = CubicSuperPath(new).to_path(curves_only=True)
# reset z to the originally closed paths
# (z lost in cubicsuperpath)
temppath = str(elem.path.to_absolute()).split("M ")
temppath.pop(0)
newPath = ""
li = 0
for sub in temppath:
newPath = newPath + "M " + temppath[li] + closed[li]
li += 1
elem.path = newPath
if nFailed > 0:
messagebox.showwarning(
"Warning",
f"""{nFailed} selected elements have no path specified.
Groups have to be ungrouped first and paths have to be
combined with Ctrl + K to be considered for joining.
Shape-elements and text will be ignored.""",
)
if nInkEffect > 0:
messagebox.showwarning(
"Warning",
f"""{nInkEffect} selected elements have an
inkscape:path-effect applied. These elements will be
ignored to avoid confusing results. Apply Paths->Object
to path (Shift+Ctrl+C) and retry .""",
)
if __name__ == "__main__":
RemoveDuplicateNodes().run()

View File

@ -0,0 +1,20 @@
[
{
"name": "Purge Duplicate Path Segments",
"id": "fablabchemnitz.de.purge_duplicate_path_segments",
"path": "purge_duplicate_path_segments",
"dependent_extensions": null,
"original_name": "Remove redundant edges",
"original_id": "org.novalis.filter.removeredundant",
"license": "GNU GPL v2",
"license_url": "https://bugs.launchpad.net/inkscape/+bug/521988/+attachment/1150930/+files/removeredundant.py",
"comment": "ported to Inkscape v1 by Mario Voigt",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/purge_duplicate_path_segments",
"fork_url": "https://bugs.launchpad.net/inkscape/+bug/521988",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Purge+Duplicate+Path+Segments",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Purge Duplicate Path Segments</name>
<id>fablabchemnitz.de.purge_duplicate_path_segments</id>
<effect>
<object-type>path</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Paths - Cut/Intersect/Purge" />
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">purge_duplicate_path_segments.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
'''
Copyright (C) 2010 David Turner <novalis@novalis.org>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St Fifth Floor, Boston, MA 02139
'''
import inkex
from inkex import paths
from collections import defaultdict
class FixedRadiusSearch():
def __init__(self, r=0.1):
self.r = r
self.seen = defaultdict(list)
def round(self, f):
return int(round(f/self.r))
def bin(self, p):
return (self.round(p[0]), self.round(p[1]))
def test(self, p, q):
return abs(self.round(p[0] - q[0])) <= 1 and abs(self.round(p[1] - q[1])) <= 1
def search(self, p):
b = self.bin(p)
for i in range(b[0]-1, b[0]+2):
for j in range(b[1]-1, b[1]+2):
for q in self.seen[(i, j)]:
if self.test(p, q):
return q
return None
def add(self, p):
self.seen[self.bin(p)].append(p)
def get_or_add(self, p):
result = self.search(p)
if result == None:
self.add(p)
return p
return result
class PurgeDuplicatePathSegments(inkex.EffectExtension):
def effect(self):
seenSegments = set()
coordsCache = FixedRadiusSearch()
for element in self.svg.selected.values():
if element.tag == inkex.addNS('path','svg'):
d = element.get('d')
path = paths.CubicSuperPath(d).to_path().to_arrays()
newPath = []
start = prev = None
pathclosed = True
for i in range(0, len(path)):
command = path[i][0]
coords = path[i][1]
newCoords = []
for x, y in zip(*[iter(coords)]*2):
newCoords.extend(list(coordsCache.get_or_add((x, y))))
coords = newCoords
tcoords = tuple(coords)
if command == 'M':
#remove this M command and it's point, if the next dataset conaints an M command too.
# Like "M 49.8584,109.276 M ..." which creates just a single point but not a valid path
if i+1 != len(path) and path[i][0] == path[i+1][0]:
continue
newPath.append([command, coords])
start = prev = tcoords
pathclosed = True
elif command == 'L':
if ('L', prev, tcoords) in seenSegments or \
('L', tcoords, prev) in seenSegments:
newPath.append(['M', coords])
pathclosed = False
else:
newPath.append([command, coords])
seenSegments.add(('L', prev, tcoords))
prev = tcoords
elif command == 'Z':
if ('L', prev, start) in seenSegments or \
('L', start, prev) in seenSegments:
newPath.append(['M', start])
else:
if pathclosed:
newPath.append([command, coords])
else:
newPath.append(['L', start])
seenSegments.add(('L', prev, start))
prev = start
elif command == 'C':
if ('C', prev, tcoords) in seenSegments or \
('C', tcoords[4:], (tcoords[2:4], tcoords[0:2], prev)) in seenSegments:
newPath.append(['M', coords[4:]])
else:
newPath.append(['C', coords])
seenSegments.add(('C', prev, tcoords))
prev = tcoords[4:]
else:
newPath.append([command, coords])
while len(newPath) and newPath[-1][0] == 'M':
newPath = newPath[:-1]
element.set('d',str(paths.Path(newPath)))
if __name__ == '__main__':
PurgeDuplicatePathSegments().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "Quick Joint",
"id": "fablabchemnitz.de.quick_joint",
"path": "quick_joint",
"dependent_extensions": null,
"original_name": "QuickJoint",
"original_id": "org.inkscape.filter.quickjoint",
"license": "GNU GPL v3",
"license_url": "https://github.com/JarrettR/QuickJoint/blob/master/LICENSE",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/quick_joint",
"fork_url": "https://github.com/JarrettR/QuickJoint",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Quick+Joint",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/JarrettR",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Quick Joint</name>
<id>fablabchemnitz.de.quick_joint</id>
<label xml:space="preserve">Adding box joint tabs or slots to selected object!</label>
<label xml:space="preserve">Version 0.3</label>
<param name="activetab" type="notebook">
<page name="tabpage" gui-text="Tabs">
<param name="side" type="int" min="0" max="512" gui-text="Side:">0</param>
<param name="numtabs" type="int" min="1" max="512" gui-text="Number of tabs:">1</param>
</page>
<page name="slotpage" gui-text="Slots">
<param name="numslots" type="int" min="1" max="512" gui-text="Number of slots:">1</param>
</page>
</param>
<param name="thickness" type="float" min="0.0" max="1000.0" precision="3" gui-text="Material thickness:">3.0</param>
<param name="kerf" type="float" min="0.0" max="1000.0" precision="5" gui-text="Laser kerf:">0.14</param>
<param name="units" type="optiongroup" appearance="combo" gui-text="Units:">
<option value="mm">mm</option>
<option value="px">px</option>
<option value="pt">pt</option>
<option value="in">in</option>
<option value="cm">cm</option>
</param>
<param name="edgefeatures" type="bool" gui-text="Features on edges">false</param>
<param name="flipside" type="bool" gui-text="Flip side">false</param>
<effect needs-live-preview="true">
<object-type>path</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz Boxes/Papercraft">
<submenu name="Finger-jointed/Tabbed Boxes" />
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">quick_joint.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,310 @@
#!/usr/bin/env python
'''
Copyright (C) 2017 Jarrett Rainier jrainier@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
'''
import inkex, cmath
from inkex.paths import Path, ZoneClose, Move
from lxml import etree
debugEn = False
def debugMsg(input):
if debugEn:
inkex.utils.debug(input)
def linesNumber(path):
retval = -1
for elem in path:
debugMsg('linesNumber')
debugMsg(elem)
retval = retval + 1
debugMsg('Number of lines : ' + str(retval))
return retval
def to_complex(point):
c = None
try:
c = complex(point.x, point.y)
except Exception as e:
pass
if c is not None:
return c
else:
inkex.utils.debug("The selection seems not be be a usable polypath. QuickJoint does not operate on curves. Try to flatten bezier curves or splitting up the path.")
exit(1)
class QuickJoint(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument('-s', '--side', type=int, default=0, help='Object face to tabify')
pars.add_argument('-n', '--numtabs', type=int, default=1, help='Number of tabs to add')
pars.add_argument('-l', '--numslots', type=int, default=1, help='Number of slots to add')
pars.add_argument('-t', '--thickness', type=float, default=3.0, help='Material thickness')
pars.add_argument('-k', '--kerf', type=float, default=0.14, help='Measured kerf of cutter')
pars.add_argument('-u', '--units', default='mm', help='Measurement units')
pars.add_argument('-e', '--edgefeatures', type=inkex.Boolean, default=False, help='Allow tabs to go right to edges')
pars.add_argument('-f', '--flipside', type=inkex.Boolean, default=False, help='Flip side of lines that tabs are drawn onto')
pars.add_argument('-a', '--activetab', default='', help='Tab or slot menus')
def to_complex(self, command, line):
debugMsg('To complex: ' + command + ' ' + str(line))
return complex(line[0], line[1])
def get_length(self, line):
polR, polPhi = cmath.polar(line)
return polR
def draw_parallel(self, start, guideLine, stepDistance):
polR, polPhi = cmath.polar(guideLine)
polR = stepDistance
return (cmath.rect(polR, polPhi) + start)
def draw_perpendicular(self, start, guideLine, stepDistance, invert = False):
polR, polPhi = cmath.polar(guideLine)
polR = stepDistance
debugMsg(polPhi)
if invert:
polPhi += (cmath.pi / 2)
else:
polPhi -= (cmath.pi / 2)
debugMsg(polPhi)
debugMsg(cmath.rect(polR, polPhi))
return (cmath.rect(polR, polPhi) + start)
def draw_box(self, start, guideLine, xDistance, yDistance, kerf):
polR, polPhi = cmath.polar(guideLine)
#Kerf expansion
if self.flipside:
start -= cmath.rect(kerf / 2, polPhi)
start -= cmath.rect(kerf / 2, polPhi + (cmath.pi / 2))
else:
start -= cmath.rect(kerf / 2, polPhi)
start -= cmath.rect(kerf / 2, polPhi - (cmath.pi / 2))
lines = []
lines.append(['M', [start.real, start.imag]])
#Horizontal
polR = xDistance
move = cmath.rect(polR + kerf, polPhi) + start
lines.append(['L', [move.real, move.imag]])
start = move
#Vertical
polR = yDistance
if self.flipside:
polPhi += (cmath.pi / 2)
else:
polPhi -= (cmath.pi / 2)
move = cmath.rect(polR + kerf, polPhi) + start
lines.append(['L', [move.real, move.imag]])
start = move
#Horizontal
polR = xDistance
if self.flipside:
polPhi += (cmath.pi / 2)
else:
polPhi -= (cmath.pi / 2)
move = cmath.rect(polR + kerf, polPhi) + start
lines.append(['L', [move.real, move.imag]])
start = move
lines.append(['Z', []])
return lines
def draw_tabs(self, path, line):
#Male tab creation
start = to_complex(path[line])
closePath = False
#Line is between last and first (closed) nodes
end = None
if isinstance(path[line+1], ZoneClose):
end = to_complex(path[0])
closePath = True
else:
end = to_complex(path[line+1])
debugMsg('start')
debugMsg(start)
debugMsg('end')
debugMsg(end)
debugMsg('5-')
if self.edgefeatures:
segCount = (self.numtabs * 2) - 1
drawValley = False
else:
segCount = (self.numtabs * 2)
drawValley = False
distance = end - start
debugMsg('distance ' + str(distance))
debugMsg('segCount ' + str(segCount))
try:
if self.edgefeatures:
segLength = self.get_length(distance) / segCount
else:
segLength = self.get_length(distance) / (segCount + 1)
except:
debugMsg('in except')
segLength = self.get_length(distance)
debugMsg('segLength - ' + str(segLength))
newLines = []
# when handling firlt line need to set M back
if isinstance(path[line], Move):
newLines.append(['M', [start.real, start.imag]])
if self.edgefeatures == False:
newLines.append(['L', [start.real, start.imag]])
start = self.draw_parallel(start, distance, segLength)
newLines.append(['L', [start.real, start.imag]])
debugMsg('Initial - ' + str(start))
for i in range(segCount):
if drawValley == True:
#Vertical
start = self.draw_perpendicular(start, distance, self.thickness, self.flipside)
newLines.append(['L', [start.real, start.imag]])
debugMsg('ValleyV - ' + str(start))
drawValley = False
#Horizontal
start = self.draw_parallel(start, distance, segLength)
newLines.append(['L', [start.real, start.imag]])
debugMsg('ValleyH - ' + str(start))
else:
#Vertical
start = self.draw_perpendicular(start, distance, self.thickness, not self.flipside)
newLines.append(['L', [start.real, start.imag]])
debugMsg('HillV - ' + str(start))
drawValley = True
#Horizontal
start = self.draw_parallel(start, distance, segLength)
newLines.append(['L', [start.real, start.imag]])
debugMsg('HillH - ' + str(start))
if self.edgefeatures == True:
start = self.draw_perpendicular(start, distance, self.thickness, self.flipside)
newLines.append(['L', [start.real, start.imag]])
debugMsg('Final - ' + str(start))
if closePath:
newLines.append(['Z', []])
return newLines
def draw_slots(self, path):
#Female slot creation
start = to_complex(path[0])
end = to_complex(path[1])
if self.edgefeatures:
segCount = (self.numslots * 2) - 1
else:
segCount = (self.numslots * 2)
distance = end - start
debugMsg('distance ' + str(distance))
debugMsg('segCount ' + str(segCount))
try:
if self.edgefeatures:
segLength = self.get_length(distance) / segCount
else:
segLength = self.get_length(distance) / (segCount + 1)
except:
segLength = self.get_length(distance)
debugMsg('segLength - ' + str(segLength))
newLines = []
line_style = str(inkex.Style({ 'stroke': '#000000', 'fill': 'none', 'stroke-width': str(self.svg.unittouu('0.1mm')) }))
for i in range(segCount):
if (self.edgefeatures and (i % 2) == 0) or (not self.edgefeatures and (i % 2)):
newLines = self.draw_box(start, distance, segLength, self.thickness, self.kerf)
debugMsg(newLines)
slot_id = self.svg.get_unique_id('slot')
g = etree.SubElement(self.svg.get_current_layer(), 'g', {'id':slot_id})
line_atts = { 'style':line_style, 'id':slot_id+'-inner-close-tab', 'd':str(Path(newLines)) }
etree.SubElement(g, inkex.addNS('path','svg'), line_atts )
#Find next point
polR, polPhi = cmath.polar(distance)
polR = segLength
start = cmath.rect(polR, polPhi) + start
def effect(self):
self.side = self.options.side
self.numtabs = self.options.numtabs
self.numslots = self.options.numslots
self.thickness = self.svg.unittouu(str(self.options.thickness) + self.options.units)
self.kerf = self.svg.unittouu(str(self.options.kerf) + self.options.units)
self.units = self.options.units
self.edgefeatures = self.options.edgefeatures
self.flipside = self.options.flipside
self.activetab = self.options.activetab
for id, node in self.svg.selected.items():
debugMsg(node)
debugMsg('1')
if node.tag == inkex.addNS('path','svg'):
p = list(node.path.to_superpath().to_segments())
debugMsg('2')
debugMsg(p)
lines = linesNumber(p)
lineNum = self.side % lines
debugMsg(lineNum)
newPath = []
if self.activetab == 'tabpage':
newPath = self.draw_tabs(p, lineNum)
debugMsg('2')
debugMsg(p[:lineNum])
debugMsg('3')
debugMsg(newPath)
debugMsg('4')
debugMsg( p[lineNum + 1:])
finalPath = p[:lineNum] + newPath + p[lineNum + 1:]
debugMsg(finalPath)
node.set('d',str(Path(finalPath)))
elif self.activetab == 'slotpage':
newPath = self.draw_slots(p)
if __name__ == '__main__':
QuickJoint().run()

View File

@ -0,0 +1,20 @@
import re
rgx_float = r"[-+]?(\d+([.,]\d*)?|[.,]\d+)([eE][-+]?\d+)?"
rgx_name = "[a-z,_]*"
optics_pattern = re.compile(
f"optics *: *(?P<material>{rgx_name})(: *(?P<num>{rgx_float}))?",
re.IGNORECASE | re.MULTILINE,
)
def get_optics_fields(string_: str):
fields = re.finditer(optics_pattern, string_)
return fields
def clear_description(desc: str) -> str:
"""Removes text corresponding to an optical property"""
new_desc = re.sub(optics_pattern, "", desc)
return new_desc

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Insert Lens Optics</name>
<id>fablabchemnitz.de.raytracing_insert_lens_optics</id>
<param name="focal_length" type="float" gui-text="Focal length:" min="-10000." max="10000." precision="3">100.</param>
<param name="focal_length_unit" type="optiongroup" appearance="combo" gui-text=" ">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="in">in</option>
</param>
<param name="diameter" type="float" gui-text="Diameter:" min="0" max="10000" precision="3">1</param>
<param name="diameter_unit" type="optiongroup" appearance="combo" gui-text=" ">
<option value="in">in</option>
<option value="mm">mm</option>
<option value="cm">cm</option>
</param>
<param name="edge_thickness" type="float" gui-text="Edge thickness:" min="0" max="10000" precision="3">2</param>
<param name="edge_thickness_unit" type="optiongroup" appearance="combo" gui-text=" ">
<option value="mm">mm</option>
<option value="in">in</option>
<option value="cm">cm</option>
</param>
<param name="optical_index" type="float" min="1." max="3." precision="4" gui-text="Optical index:">1.5168</param>
<param name="lens_type" type="optiongroup" appearance="combo" gui-text="Lens type:">
<option value="plano_con">Plano-concave/convex</option>
<option value="bi_con">Bi-concave/convex</option>
</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Ray Tracing"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">lens.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,231 @@
"""
Module to add a lens object in the document
"""
from math import cos, pi, sin, sqrt, acos, tan
import inkex
class Lens(inkex.GenerateExtension):
"""
Produces a PathElement corresponding to the shape of the lens calculated
from user parameters.
"""
@property
def style(self):
return {
"stroke": "#000000",
"fill": "#b7c2dd",
"stroke-linejoin": "round",
"stroke-width": str(self.svg.unittouu("1px")),
}
@staticmethod
def add_arguments(pars):
pars.add_argument("--focal_length", type=float, default=100.0)
pars.add_argument("--focal_length_unit", type=str, default="mm")
pars.add_argument("--diameter", type=float, default=1.0)
pars.add_argument("--diameter_unit", default="in")
pars.add_argument("--edge_thickness", type=float, default=2.0)
pars.add_argument("--edge_thickness_unit", default="mm")
pars.add_argument("--optical_index", type=float, default=1.5168)
pars.add_argument("--lens_type", default="plano_con")
def to_document_units(self, value: float, unit: str) -> float:
c1x, c1y, c2x, c2y = self.svg.get_viewbox()
document_width = self.svg.unittouu(self.document.getroot().get("width"))
scale_factor = (c2x - c1x) / document_width
return self.svg.unittouu(str(value) + unit) * scale_factor
def generate(self):
opts = self.options
d = self.svg.viewport_to_unit(f"{opts.diameter}{opts.diameter_unit}")
f = self.svg.viewport_to_unit(f"{opts.focal_length}{opts.focal_length_unit}")
e = self.svg.viewport_to_unit(
f"{opts.edge_thickness}{opts.edge_thickness_unit}")
optical_index = opts.optical_index
lens_path = []
if opts.lens_type == "plano_con":
# Radius of curvature from Lensmaker's equation
roc = (optical_index - 1) * abs(f)
if 2 * roc < d:
inkex.utils.errormsg(
"Focal length is too short or diameter is too large."
)
return None
elif (roc ** 2 - (d / 2) ** 2) ** 0.5 - roc < -e and f < 0:
inkex.utils.errormsg("Edge thickness is too small.")
return None
else:
sweep = 1 if f < 0 else 0
lens_path = arc_to_path(
[-d / 2, 0], [roc, roc, 0.0, 0, sweep, +d / 2, 0]
)
lens_path += [
[[+d / 2, 0], [+d / 2, 0], [+d / 2, -e]],
[[+d / 2, -e], [+d / 2, -e], [-d / 2, -e]],
[[+d / 2, -e], [-d / 2, -e], [-d / 2, +e]],
]
# no need to close the path correctly as it's done after
elif opts.lens_type == "bi_con":
roc = (
(optical_index - 1) * abs(f) * (1 + (1 - e / f / optical_index) ** 0.5)
)
if 2 * roc < d:
inkex.utils.errormsg(
"Focal length is too short or diameter is too large."
)
return None
elif (roc ** 2 - (d / 2) ** 2) ** 0.5 - roc < -e / 2 and f < 0:
inkex.utils.errormsg("Edge thickness is too small.")
return None
else:
sweep = 1 if f < 0 else 0
lens_path = arc_to_path(
[-d / 2, 0], [roc, roc, 0.0, 0, sweep, +d / 2, 0]
)
lens_path += [
[[+d / 2, 0], [+d / 2, 0], [+d / 2, -e]],
[[+d / 2, -e], [+d / 2, -e], [+d / 2, -e]],
]
lens_path += arc_to_path(
[+d / 2, -e], [roc, roc, 0.0, 0, sweep, -d / 2, -e]
)
lens_path += [
[[-d / 2, -e], [-d / 2, -e], [-d / 2, 0]],
[[-d / 2, -e], [-d / 2, 0], [-d / 2, 0]],
]
lens = inkex.PathElement()
lens.style = self.style
closed_path = inkex.Path(inkex.CubicSuperPath([lens_path]))
closed_path.close()
lens.path = closed_path.transform(inkex.Transform("rotate(90)"))
lens.desc = (
f"L{opts.focal_length}{opts.focal_length_unit}\n"
f"optics:glass:{optical_index:.4f}"
)
yield lens
def arc_to_path(point, params):
"""Approximates an arc with cubic bezier segments.
Arguments:
point: Starting point (absolute coords)
params: Arcs parameters as per
https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands
Returns a list of triplets of points : [control_point_before, node, control_point_after]
(first and last returned triplets are [p1, p1, *] and [*, p2, p2])
"""
A = point[:]
rx, ry, theta, long_flag, sweep_flag, x2, y2 = params[:]
theta = theta * pi / 180.0
B = [x2, y2]
# Degenerate ellipse
if rx == 0 or ry == 0 or A == B:
return [[A[:], A[:], A[:]], [B[:], B[:], B[:]]]
# turn coordinates so that the ellipse morph into a *unit circle* (not 0-centered)
mat = mat_prod(
(rot_mat(theta), [[1.0 / rx, 0.0], [0.0, 1.0 / ry]], rot_mat(-theta))
)
apply_mat(mat, A)
apply_mat(mat, B)
k = [-(B[1] - A[1]), B[0] - A[0]]
d = k[0] * k[0] + k[1] * k[1]
k[0] /= sqrt(d)
k[1] /= sqrt(d)
d = sqrt(max(0, 1 - d / 4.0))
# k is the unit normal to AB vector, pointing to center O
# d is distance from center to AB segment (distance from O to the midpoint of AB)
# for the last line, remember this is a unit circle, and kd vector is orthogonal to AB (Pythagorean thm)
if (
long_flag == sweep_flag
): # top-right ellipse in SVG example https://www.w3.org/TR/SVG/images/paths/arcs02.svg
d *= -1
O = [(B[0] + A[0]) / 2.0 + d * k[0], (B[1] + A[1]) / 2.0 + d * k[1]]
OA = [A[0] - O[0], A[1] - O[1]]
OB = [B[0] - O[0], B[1] - O[1]]
start = acos(OA[0] / norm(OA))
if OA[1] < 0:
start *= -1
end = acos(OB[0] / norm(OB))
if OB[1] < 0:
end *= -1
# start and end are the angles from center of the circle to A and to B respectively
if sweep_flag and start > end:
end += 2 * pi
if (not sweep_flag) and start < end:
end -= 2 * pi
nb_sectors = int(abs(start - end) * 2 / pi) + 1
d_theta = (end - start) / nb_sectors
v = 4 * tan(d_theta / 4.0) / 3.0
# I would use v = tan(d_theta/2)*4*(sqrt(2)-1)/3 ?
p = []
for i in range(0, nb_sectors + 1, 1):
angle = start + i * d_theta
v1 = [
O[0] + cos(angle) - (-v) * sin(angle),
O[1] + sin(angle) + (-v) * cos(angle),
]
pt = [O[0] + cos(angle), O[1] + sin(angle)]
v2 = [O[0] + cos(angle) - v * sin(angle), O[1] + sin(angle) + v * cos(angle)]
p.append([v1, pt, v2])
p[0][0] = p[0][1][:]
p[-1][2] = p[-1][1][:]
# go back to the original coordinate system
mat = mat_prod((rot_mat(theta), [[rx, 0], [0, ry]], rot_mat(-theta)))
for pts in p:
apply_mat(mat, pts[0])
apply_mat(mat, pts[1])
apply_mat(mat, pts[2])
return p
def mat_prod(m_list):
"""Get the product of the mat"""
prod = m_list[0]
for mat in m_list[1:]:
a00 = prod[0][0] * mat[0][0] + prod[0][1] * mat[1][0]
a01 = prod[0][0] * mat[0][1] + prod[0][1] * mat[1][1]
a10 = prod[1][0] * mat[0][0] + prod[1][1] * mat[1][0]
a11 = prod[1][0] * mat[0][1] + prod[1][1] * mat[1][1]
prod = [[a00, a01], [a10, a11]]
return prod
def rot_mat(theta):
"""Rotate the mat"""
return [[cos(theta), -sin(theta)], [sin(theta), cos(theta)]]
def apply_mat(mat, point):
"""Apply the given mat"""
x = mat[0][0] * point[0] + mat[0][1] * point[1]
y = mat[1][0] * point[0] + mat[1][1] * point[1]
point[0] = x
point[1] = y
def norm(point):
"""Normalise"""
return sqrt(point[0] * point[0] + point[1] * point[1])
if __name__ == "__main__":
Lens().run()

View File

@ -0,0 +1,21 @@
[
{
"name": "<various>",
"id": "fablabchemnitz.de.raytracing.<various>",
"path": "raytracing",
"dependent_extensions": null,
"original_name": "<various>",
"original_id": "damienBloch/inkscape-raytracing/<various>",
"license": "GNU GPL v3",
"license_url": "https://github.com/damienBloch/inkscape-raytracing/blob/master/LICENSE",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/raytracing",
"fork_url": "https://github.com/damienBloch/inkscape-raytracing",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/Ray+Tracing",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/damienBloch",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,4 @@
from .optical_object import *
from .ray import *
from .vector import *
from .world import *

View File

@ -0,0 +1,2 @@
from .cubic_bezier import CubicBezier
from .geometric_object import GeometricObject, CompoundGeometricObject, AABBox

View File

@ -0,0 +1,210 @@
"""
Module for handling objects composed of cubic bezier curves
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from functools import cached_property
import numpy
from .geometric_object import AABBox, GeometricObject, GeometryError
from ..ray import Ray
from ..shade import ShadeRec
from ..vector import Vector, UnitVector
@dataclass(frozen=True)
class CubicBezier:
r"""
Cubic bezier segment defined as
.. math::
\vec{X}(s) = (1-s)^3 \vec{p_0} + 3 s (1-s)^2 \vec{p_1}
+ 3 s^2 (1-s) \vec{p_2} + s^3 \vec{p_3}
for :math:`0 \le s \le 1`
"""
p0: Vector
p1: Vector
p2: Vector
p3: Vector
def eval(self, s) -> Vector:
return (
(1 - s) ** 3 * self.p0
+ 3 * s * (1 - s) ** 2 * self.p1
+ 3 * s ** 2 * (1 - s) * self.p2
+ s ** 3 * self.p3
)
@cached_property
def aabbox(self) -> AABBox:
# The box is slightly larger than the minimal box.
# It prevents the box to have a zero dimension if the object is a line
# aligned with vertical or horizontal.
lower_left = Vector(
min(self.p0.x, self.p1.x, self.p2.x, self.p3.x) - 1e-6,
min(self.p0.y, self.p1.y, self.p2.y, self.p3.y) - 1e-6,
)
upper_right = Vector(
max(self.p0.x, self.p1.x, self.p2.x, self.p3.x) + 1e-6,
max(self.p0.y, self.p1.y, self.p2.y, self.p3.y) + 1e-6,
)
return AABBox(lower_left, upper_right)
def tangent(self, s: float) -> UnitVector:
"""Returns the tangent at the curve at curvilinear coordinate s"""
diff_1 = (
-3 * (self.p0 - 3 * self.p1 + 3 * self.p2 - self.p3) * s ** 2
+ 6 * (self.p0 - 2 * self.p1 + self.p2) * s
- 3 * (self.p0 - self.p1)
)
# If the first derivative is not zero, it is parallel to the tangent
if diff_1.norm() > 1e-8:
return diff_1.normalize()
# but is the first derivative is zero, we need to get the second order
else:
diff_2 = -6 * (self.p0 - 3 * self.p1 + 3 * self.p2 - self.p3) * s + 6 * (
self.p0 - 2 * self.p1 + self.p2
)
if diff_2.norm() > 1e-8:
return diff_2.normalize()
else: # and even to the 3rd derivative if necessary
diff_3 = -6 * (self.p0 - 3 * self.p1 + 3 * self.p2 - self.p3)
return diff_3.normalize()
def normal(self, s: float) -> UnitVector:
"""Returns a vector normal at the curve at curvilinear coordinate s"""
return self.tangent(s).orthogonal()
def intersection_beam(self, ray: Ray) -> list[tuple[float, float]]:
r"""
Returns all couples :math:`(s, t)` such that there exist
:math:`\vec{X}` satisfying
.. math::
\vec{X} = (1-s)^3 \vec{p_0} + 3 s (1-s)^2 \vec{p_1}
+ 3 s^2 (1-s) \vec{p_2} + s^3 \vec{p_3}
and
.. math::
\vec{X} = \vec{o} + t \vec{d}
with :math:`0 \lq s \lq 1` and :math:`t >= 0`
"""
a = ray.direction.orthogonal()
a0 = a * (self.p0 - ray.origin)
a1 = -3 * a * (self.p0 - self.p1)
a2 = 3 * a * (self.p0 - 2 * self.p1 + self.p2)
a3 = a * (-self.p0 + 3 * self.p1 - 3 * self.p2 + self.p3)
roots = cubic_real_roots(a0, a1, a2, a3)
intersection_points = [self.eval(s) for s in roots]
travel = [(X - ray.origin) * ray.direction for X in intersection_points]
def valid_domain(s, t):
return 0 <= s <= 1 and t > Ray.min_travel
return [(s, t) for (s, t) in zip(roots, travel) if valid_domain(s, t)]
def num_hits(self, ray: Ray) -> int:
if self.aabbox.hit(ray):
return len(self.intersection_beam(ray))
else:
return 0
def hit(self, ray: Ray) -> ShadeRec:
"""
Returns a shade with the information for the first intersection
of a beam with the bezier segment
"""
shade = ShadeRec() # default no hit
if self.aabbox.hit(ray):
intersect_params = self.intersection_beam(ray)
travel_dist = [t for (__, t) in intersect_params]
if len(travel_dist) > 0: # otherwise error with np.argmin
shade.normal = True
first_hit = numpy.argmin(travel_dist)
shade.travel_dist = travel_dist[first_hit]
shade.local_hit_point = ray.origin + shade.travel_dist * ray.direction
shade.normal = self.normal(intersect_params[first_hit][0])
shade.set_normal_same_side(ray.origin)
return shade
def is_inside(self, ray: Ray) -> bool:
raise GeometryError(f"Can't define an inside for {self}.")
def cubic_real_roots(d: float, c: float, b: float, a: float) -> list[float]:
"""
Returns the real roots X of a cubic polynomial defined as
.. math::
a X^3 + b X^2 + c X + d = 0
"""
# For more information see:
# https://en.wikipedia.org/wiki/Cubic_equation#General_cubic_formula
if not is_almost_zero(a): # true cubic equation
p = (3 * a * c - b ** 2) / 3 / a ** 2
q = (2 * b ** 3 - 9 * a * b * c + 27 * a ** 2 * d) / 27 / a ** 3
if is_almost_zero(p):
t = [numpy.cbrt(-q)]
else:
discr = -(4 * p ** 3 + 27 * q ** 2)
if is_almost_zero(discr):
if is_almost_zero(q):
t = [0]
else:
t = [3 * q / p, -3 * q / 2 / p]
elif discr < 0:
t = [
numpy.cbrt(-q / 2 + numpy.sqrt(-discr / 108))
+ numpy.cbrt(-q / 2 - numpy.sqrt(-discr / 108))
]
else:
t = [
2
* numpy.sqrt(-p / 3)
* numpy.cos(
1 / 3 * numpy.arccos(3 * q / 2 / p * numpy.sqrt(-3 / p))
- 2 * numpy.pi * k / 3
)
for k in range(3)
]
return [x - b / 3 / a for x in t]
else:
return quadratic_roots(b, c, d)
def quadratic_roots(a: float, b: float, c: float) -> list[float]:
if not is_almost_zero(a):
discr = b ** 2 - 4 * a * c
if discr > 0:
return [
(-b + numpy.sqrt(discr)) / 2 / a,
(-b - numpy.sqrt(discr)) / 2 / a,
]
elif is_almost_zero(discr):
return [-b / 2 / a]
else:
return []
else:
return linear_root(b, c)
def linear_root(a: float, b: float) -> list[float]:
if is_almost_zero(a): # No solutions for 0*X+b=0
return [] # Ignore infinite solutions for a=b=0
else:
return [-b / a]
def is_almost_zero(x: float) -> bool:
return math.isclose(x, 0, abs_tol=1e-8)

View File

@ -0,0 +1,151 @@
from __future__ import annotations
import functools
from dataclasses import dataclass
from typing import Protocol, Iterable, TypeVar, Generic
import numpy
from ..ray import Ray
from ..shade import ShadeRec
from ..vector import Vector
class GeometricObject(Protocol):
"""Protocol for a geometric object (line, rectangle, circle, ...)"""
def hit(self, ray: Ray) -> ShadeRec:
"""Tests if a collision between a beam and the object occurred
Returns a shade that contains the information about the collision in
case it happened.
"""
raise NotImplementedError
def num_hits(self, ray: Ray) -> int:
"""Returns the number of times a beam intersect the object boundary"""
raise NotImplementedError
@property
def aabbox(self) -> AABBox:
"""Computes an axis aligned bounding box for the object"""
raise NotImplementedError
def is_inside(self, ray: Ray) -> bool:
"""Indicates if a ray is inside or outside the object
It is not possible to define an inside for every object, for example if it is
not closed. In this case it should raise a GeometryError.
"""
raise NotImplementedError
class GeometryError(RuntimeError):
pass
T = TypeVar("T", bound=GeometricObject)
@dataclass(frozen=True)
class CompoundGeometricObject(GeometricObject, Generic[T]):
sub_objects: tuple[T, ...]
def __init__(self, sub_objects: Iterable[T]):
object.__setattr__(self, "sub_objects", tuple(sub_objects))
def __iter__(self) -> Iterable[T]:
return iter(self.sub_objects)
def __getitem__(self, item) -> T:
return self.sub_objects[item]
@functools.cached_property
def aabbox(self):
sub_boxes = (sub.aabbox for sub in self.sub_objects)
return AABBox.englobing(sub_boxes)
def hit(self, ray: Ray) -> ShadeRec:
"""
Returns a shade with the information for the first intersection
of a beam with one of the object composing the composite object
"""
result = ShadeRec()
if self.aabbox.hit(ray):
result = find_first_hit(ray, self.sub_objects)
result.hit_geometry = self
return result
def is_inside(self, ray: Ray) -> bool:
# A ray is inside an object if it intersect its boundary an odd
# number of times
return (self.num_hits(ray) % 2) == 1
def num_hits(self, ray: Ray) -> int:
if self.aabbox.hit(ray):
return sum([obj.num_hits(ray) for obj in self.sub_objects])
else:
return 0
def find_first_hit(ray: Ray, objects: Iterable[GeometricObject]) -> ShadeRec:
result = ShadeRec()
for obj in objects:
shade = obj.hit(ray)
if Ray.min_travel < shade.travel_dist < result.travel_dist:
result = shade
return result
@dataclass(frozen=True)
class AABBox:
"""
Implements an axis-aligned bounding box
This is used to accelerate the intersection between a beam and an object.
If the beam doesn't hit the bounding box, it is not necessary to do
expensive intersection calculations with the object.
"""
lower_left: Vector
upper_right: Vector
@classmethod
def englobing(cls, aabboxes: Iterable[AABBox]) -> AABBox:
return functools.reduce(cls.englobing_two, aabboxes)
@classmethod
def englobing_two(cls, b1: AABBox, b2: AABBox) -> AABBox:
union_lower_left = Vector(
min(b1.lower_left.x, b2.lower_left.x),
min(b1.lower_left.y, b2.lower_left.y),
)
union_upper_right = Vector(
max(b1.upper_right.x, b2.upper_right.x),
max(b1.upper_right.y, b2.upper_right.y),
)
return AABBox(union_lower_left, union_upper_right)
def hit(self, ray: Ray) -> bool:
"""Tests if a beam intersects the bounding box"""
# This algorithm uses the properties of IEEE floating-point
# arithmetic to correctly handle cases where the ray travels
# parallel to a coordinate axis.
# See Williams et al. "An efficient and robust ray-box intersection
# algorithm" for more details.
p0 = numpy.array([self.lower_left.x, self.lower_left.y])
p1 = numpy.array([self.upper_right.x, self.upper_right.y])
direction = numpy.array([ray.direction.x, ray.direction.y])
origin = numpy.array([ray.origin.x, ray.origin.y])
# The implementation safely handles the case where an element
# of ray.direction is zero. Warning for floating point error
# can be ignored for this step.
with numpy.errstate(invalid="ignore", divide="ignore"):
a = 1 / direction
t_min = (numpy.where(a >= 0, p0, p1) - origin) * a
t_max = (numpy.where(a >= 0, p1, p0) - origin) * a
t0 = numpy.max(t_min)
t1 = numpy.min(t_max)
return (t0 < t1) and (t1 > Ray.min_travel)

View File

@ -0,0 +1,5 @@
from .optic_material import *
from .beamdump import *
from .mirror import *
from .beamsplitter import *
from .glass import *

View File

@ -0,0 +1,15 @@
from __future__ import annotations
from .optic_material import OpticMaterial
from ..ray import Ray
from ..shade import ShadeRec
class BeamDump(OpticMaterial):
"""Material absorbing all beams that hit it"""
def __repr__(self):
return "BeamDump()"
def generated_beams(self, ray: Ray, shade: ShadeRec) -> list[Ray]:
return list()

View File

@ -0,0 +1,27 @@
from typing import List
import numpy as np
from ..ray import Ray
from ..shade import ShadeRec
from .optic_material import OpticMaterial
class BeamSplitter(OpticMaterial):
"""
Material producing two beams after collision. One is reflected and
the other is transmitted.
"""
def __init__(self):
super().__init__()
def __repr__(self):
return "Mirror()"
def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]:
o, d = shade.local_hit_point, ray.direction
n = shade.normal
reflected_ray = Ray(o, d - 2 * np.dot(d, n) * n)
transmitted_ray = Ray(o, d)
return [reflected_ray, transmitted_ray]

View File

@ -0,0 +1,39 @@
from typing import List
import numpy as np
from ..ray import Ray
from ..shade import ShadeRec
from .optic_material import OpticMaterial
class Glass(OpticMaterial):
"""Material that transmits and bends beams hitting it"""
def __init__(self, optical_index):
self._optical_index = optical_index
@property
def optical_index(self):
return self._optical_index
def __repr__(self):
return f"Glass({self._optical_index})"
def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]:
o, d = shade.local_hit_point, ray.direction
n = shade.normal
if shade.hit_geometry.is_inside(ray):
n_1, n_2 = self.optical_index, 1
else:
n_1, n_2 = 1, self.optical_index
r = n_1 / n_2
c1 = -np.dot(d, n)
u = 1 - r ** 2 * (1 - c1 ** 2)
if u < 0: # total internal reflection
reflected_ray = Ray(o, d - 2 * np.dot(d, n) * n)
return [reflected_ray]
else: # refraction
c2 = np.sqrt(u)
transmitted_ray = Ray(o, r * d + (r * c1 - c2) * n)
return [transmitted_ray]

View File

@ -0,0 +1,20 @@
from typing import List
import numpy
from ..ray import Ray
from ..shade import ShadeRec
from .optic_material import OpticMaterial
class Mirror(OpticMaterial):
"""Material reflecting beams that hit it"""
def __repr__(self):
return "Mirror()"
def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]:
o, d = shade.local_hit_point, ray.direction
n = shade.normal
reflected_ray = Ray(o, d - 2 * numpy.dot(d, n) * n)
return [reflected_ray]

View File

@ -0,0 +1,19 @@
from abc import abstractmethod
from typing import Protocol, List
from ..ray import Ray
from ..shade import ShadeRec
class OpticMaterial(Protocol):
"""Protocol for an optical material"""
@abstractmethod
def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]:
"""Compute the beams generated after intersection of a beam with this
material
Returns list of new beam seeds to start from after the intersection
of a beam and an object.
"""
raise NotImplementedError

View File

@ -0,0 +1,10 @@
from dataclasses import dataclass
from .geometry import GeometricObject
from .material import OpticMaterial
@dataclass(frozen=True)
class OpticalObject:
geometry: GeometricObject
material: OpticMaterial

View File

@ -0,0 +1,18 @@
from dataclasses import dataclass
from typing import ClassVar
from .vector import UnitVector, Vector
@dataclass(frozen=True)
class Ray:
"""This class implements a 2D line with an origin point and a direction."""
origin: Vector
direction: UnitVector
travel: float = 0
# If a beam hits an object before having traveled a minimum distance
# from its origin, the collision is ignored. This prevents infinite
# collision in case the origin of a beam is on the surface of an object
min_travel: ClassVar[float] = 1e-7

View File

@ -0,0 +1,35 @@
import numpy as np
from typing import Optional
class ShadeRec(object):
"""
This object contains the information needed to process the collision
between a ray and an object.
"""
def __init__(self):
self.hit_an_object: bool = False
self.local_hit_point: Optional[np.ndarray] = None
self.normal: Optional[np.ndarray] = None
self.travel_dist: float = np.inf
from .geometry import GeometricObject
self.hit_geometry: Optional[GeometricObject] = None
def __repr__(self):
return (
f"ShadeRec({self.hit_an_object}, {self.local_hit_point}, "
f"{self.normal}, {self.travel_dist})"
)
def set_normal_same_side(self, point: np.ndarray):
if self.normal is None:
raise RuntimeError("Can't find normal orientation if not already defined.")
elif self.local_hit_point is None:
raise RuntimeError(
"Can't find normal orientation if hit point not defined."
)
elif np.dot(self.normal, self.local_hit_point - point) > 0:
self.normal = -self.normal

View File

@ -0,0 +1,75 @@
from __future__ import annotations
from dataclasses import dataclass, field
from functools import singledispatchmethod
from math import sqrt
from numbers import Real
@dataclass(frozen=True)
class Vector:
x: float = field()
y: float = field()
def orthogonal(self) -> Vector:
"""Return a vector obtained by a pi/2 rotation"""
return UnitVector(-self.y, self.x)
@singledispatchmethod
def __mul__(self, other):
raise NotImplementedError
@__mul__.register
def _(self, other: Real):
return Vector(self.x * other, self.y * other)
@singledispatchmethod
def __rmul__(self, other):
raise NotImplementedError(type(other))
@__rmul__.register
def _(self, other: Real):
return Vector(self.x * other, self.y * other)
@singledispatchmethod
def __add__(self, other) -> Vector:
raise NotImplementedError
@singledispatchmethod
def __sub__(self, other) -> Vector:
raise NotImplementedError
def __neg__(self) -> Vector:
return Vector(-self.x, -self.y)
def norm(self):
return sqrt(self * self)
def normalize(self) -> UnitVector:
return UnitVector(self.x, self.y)
@dataclass(frozen=True)
class UnitVector(Vector):
def __init__(self, x, y):
norm = sqrt(x ** 2 + y ** 2)
super().__init__(x / norm, y / norm)
@Vector.__add__.register
def _(self, other: Vector):
return Vector(self.x + other.x, self.y + other.y)
@Vector.__sub__.register
def _(self, other: Vector):
return Vector(self.x - other.x, self.y - other.y)
@Vector.__mul__.register
def _(self, other: Vector) -> float:
return self.x * other.x + self.y * other.y
@Vector.__rmul__.register
def _(self, other: Vector) -> float:
return self.x * other.x + self.y * other.y

View File

@ -0,0 +1,93 @@
"""
Module to describe and interact with a scene composed of various optical
objects
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import Optional, List, NamedTuple, Iterable, Tuple
from .geometry import GeometricObject
from .material import OpticMaterial, BeamDump
from .ray import Ray
from .shade import ShadeRec
class OpticalObject(NamedTuple):
geometry: GeometricObject
material: OpticMaterial
@dataclass
class World:
"""Stores a scene and computes the interaction with a ray"""
objects: Optional[list[OpticalObject]] = field(default_factory=list)
# default recursion depth can be changed, but should not exceed
# system recursion limit.
max_recursion_depth: Optional[int] = 500
def add(self, obj: OpticalObject):
self.objects.append(obj)
def __iter__(self) -> Iterable[OpticalObject]:
return iter(self.objects)
@property
def num_objects(self) -> int:
return len(self.objects)
def first_hit(self, ray: Ray) -> Tuple[ShadeRec, OpticMaterial]:
"""
Returns the information about the first collision of the beam
with an object.
:return: A shade for the collision geometric information and the
material of the object hit.
"""
result = ShadeRec()
material = BeamDump()
for obj in self:
shade = obj.geometry.hit(ray)
if Ray.min_travel < shade.travel_dist < result.travel_dist:
result = shade
material = obj.material
return result, material
def propagate_beams(self, seed):
return self._propagate_beams([[seed]], 0)
def _propagate_beams(self, beams: List[List[Ray]], depth) -> List[List[Ray]]:
"""Computes the propagation of beams in the system
:return: List of all the beam paths generated by these seeds.
It is stored as
[path0[Ray0, Ray1, ...], path1[...], ...].
Each path is a list of successive rays having each traveled a
given distance.
:raise: warning if recursion depth hits a limit.
"""
if depth >= self.max_recursion_depth:
err_msg = (
f"Maximal recursion depth exceeded ({self.max_recursion_depth})."
"It is likely that not all beams have been rendered."
)
warnings.warn(err_msg)
return beams
else:
new_beams = list()
for index, beam in enumerate(beams):
ray = beam[-1]
if ray.travel <= 0:
shade, material = self.first_hit(ray)
new_seeds = material.generated_beams(ray, shade)
beams[index][-1] = Ray(ray.origin, ray.direction, shade.travel_dist)
if len(new_seeds) == 0:
new_beams.append(beams[index])
for seed in new_seeds:
generated_beams = self._propagate_beams([[seed]], depth + 1)
for new_beam in generated_beams:
new_beams.append(beams[index] + new_beam)
return new_beams

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Render</name>
<id>fablabchemnitz.de.raytracing.render</id>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Ray Tracing"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">render.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,289 @@
"""
Extension for rendering beams in 2D optics with Inkscape
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import singledispatchmethod
from typing import Iterable, Optional, Final
import inkex
from inkex.paths import Line, Move
import raytracing.material
from desc_parser import get_optics_fields
from raytracing import Vector, World, OpticalObject, Ray
from raytracing.geometry import CubicBezier, CompoundGeometricObject, GeometricObject
from utils import pairwise
@dataclass
class BeamSeed:
ray: Optional[Ray] = None
parent: Optional[inkex.ShapeElement] = None
def get_unlinked_copy(clone: inkex.Use) -> Optional[inkex.ShapeElement]:
"""Creates a copy of the original with all transformations applied"""
copy = clone.href.copy()
copy.transform = clone.composed_transform() * copy.transform
copy.style = clone.specified_style()
copy.getparent = clone.getparent
return copy
def get_or_create_beam_layer(parent_layer: inkex.Layer) -> inkex.Layer:
for child in parent_layer:
if isinstance(child, inkex.Layer):
if child.get("inkscape:label") == "generated_beams":
return child
new_layer = parent_layer.add(inkex.Layer())
new_layer.label = "generated_beams"
return new_layer
def plot_beam(beam: list[Ray], node: inkex.ShapeElement, layer: inkex.Layer):
path = inkex.Path()
if beam:
path += [Move(beam[0].origin.x, beam[0].origin.y)]
for ray in beam:
p1 = ray.origin + ray.travel * ray.direction
path += [Line(p1.x, p1.y)]
element = layer.add(inkex.PathElement())
# Need to convert to path to get the correct style for inkex.Use
element.style = node.specified_style()
element.path = path
class Raytracing(inkex.EffectExtension):
"""Extension to renders the beams present in the document"""
# Ray tracing is only implemented for the following inkex primitives
filter_primitives: Final = (
inkex.PathElement,
inkex.Line,
inkex.Polyline,
inkex.Polygon,
inkex.Rectangle,
inkex.Ellipse,
inkex.Circle,
)
def __init__(self):
super().__init__()
self.world = World()
self.beam_seeds: list[BeamSeed] = list()
def effect(self) -> None:
"""
Loads the objects and outputs a svg with the beams after propagation
"""
# Can't set the border earlier because self.svg is not yet defined
self.document_border = self.get_document_borders_as_beamdump()
self.world.add(self.document_border)
filter_ = self.filter_primitives + (inkex.Group, inkex.Use)
for obj in self.svg.selection.filter(filter_):
self.add(obj)
if self.beam_seeds:
for seed in self.beam_seeds:
if self.is_inside_document(seed.ray):
generated = self.world.propagate_beams(seed.ray)
for beam in generated:
try:
new_layer = get_or_create_beam_layer(
get_containing_layer(seed.parent)
)
plot_beam(beam, seed.parent, new_layer)
except LayerError as e:
inkex.utils.errormsg(f"{e} It will be ignored.")
@singledispatchmethod
def add(self, obj):
pass
@add.register
def _(self, group: inkex.Group):
for child in group:
self.add(child)
@add.register
def _(self, clone: inkex.Use):
copy = get_unlinked_copy(clone)
self.add(copy)
for type in filter_primitives:
@add.register(type)
def _(self, obj):
"""
Extracts properties and adds the object to the ray tracing data
structure
"""
material = get_material(obj)
if material:
if isinstance(material, BeamSeed):
for ray in get_beams(obj):
self.beam_seeds.append(BeamSeed(ray, parent=obj))
else:
geometry = get_geometry(obj)
opt_obj = OpticalObject(geometry, material)
self.world.add(opt_obj)
def get_document_borders_as_beamdump(self) -> OpticalObject:
"""
Adds a beam blocking contour on the borders of the document to
prevent the beams from going to infinity
"""
c1x, c1y, c2x, c2y = self.svg.get_viewbox()
contour_geometry = CompoundGeometricObject(
(
CubicBezier(
Vector(c1x, c1y),
Vector(c1x, c1y),
Vector(c2x, c1y),
Vector(c2x, c1y),
),
CubicBezier(
Vector(c2x, c1y),
Vector(c2x, c1y),
Vector(c2x, c2y),
Vector(c2x, c2y),
),
CubicBezier(
Vector(c2x, c2y),
Vector(c2x, c2y),
Vector(c1x, c2y),
Vector(c1x, c2y),
),
CubicBezier(
Vector(c1x, c2y),
Vector(c1x, c2y),
Vector(c1x, c1y),
Vector(c1x, c1y),
),
)
)
return OpticalObject(contour_geometry, raytracing.material.BeamDump())
def is_inside_document(self, ray: Ray) -> bool:
return self.document_border.geometry.is_inside(ray)
def get_material(
obj: inkex.ShapeElement,
) -> Optional[raytracing.material.OpticMaterial | BeamSeed]:
"""Extracts the optical material of an object from its description"""
desc = obj.desc
if desc is None:
desc = ""
materials = get_materials_from_description(desc)
if len(materials) == 0:
return None
if len(materials) > 1:
raise_err_num_materials(obj)
elif len(materials) == 1:
return materials[0]
def get_materials_from_description(
desc: str,
) -> list[raytracing.material.OpticMaterial | BeamSeed]:
"""Run through the description to extract the material properties"""
materials = list()
class_alias = dict(
beam_dump=raytracing.material.BeamDump,
mirror=raytracing.material.Mirror,
beam_splitter=raytracing.material.BeamSplitter,
glass=raytracing.material.Glass,
beam=BeamSeed,
)
for match in get_optics_fields(desc):
material_type = match.group("material")
prop_str = match.group("num")
if material_type in class_alias:
if material_type == "glass" and prop_str is not None:
optical_index = float(prop_str)
materials.append(class_alias[material_type](optical_index))
else:
materials.append(class_alias[material_type]())
return materials
def raise_err_num_materials(obj):
inkex.utils.errormsg(
f"The element {obj.get_id()} has more than one optical material and will be"
f" ignored:\n{obj.desc}\n"
)
def get_geometry(obj: inkex.ShapeElement) -> GeometricObject:
"""
Converts the geometry of inkscape elements to a form suitable for the
ray tracing module
"""
# Treats all objects as cubic Bezier curves. This treatment is exact
# for most primitives except circles and ellipses that are only
# approximated by Bezier curves.
# TODO: implement exact representation for ellipses
path = get_absolute_path(obj)
composite_bezier = convert_to_composite_bezier(path)
return composite_bezier
def get_absolute_path(obj: inkex.ShapeElement) -> inkex.CubicSuperPath:
path = obj.to_path_element().path.to_absolute()
transformed_path = path.transform(obj.composed_transform())
return transformed_path.to_superpath()
def get_beams(element: inkex.ShapeElement) -> Iterable[Ray]:
"""
Returns a beam with origin at the endpoint of the path and tangent to
the path
"""
bezier_path = convert_to_composite_bezier(get_absolute_path(element))
for sub_path in bezier_path:
last_segment = sub_path[-1]
endpoint = last_segment.eval(1)
tangent = -last_segment.tangent(1)
yield Ray(endpoint, tangent)
def convert_to_composite_bezier(
superpath: inkex.CubicSuperPath,
) -> CompoundGeometricObject:
"""
Converts a superpath with a representation
[Subpath0[handle0_0, point0, handle0_1], ...], ...]
to a representation of consecutive bezier segments of the form
CompositeCubicBezier([CubicBezierPath[CubicBezier[point0, handle0_1,
handle1_0, point1], ...], ...]).
"""
composite_bezier = list()
for subpath in superpath:
bezier_path = list()
for (__, p0, p1), (p2, p3, __) in pairwise(subpath):
bezier = CubicBezier(Vector(*p0), Vector(*p1), Vector(*p2), Vector(*p3))
bezier_path.append(bezier)
composite_bezier.append(CompoundGeometricObject(bezier_path))
return CompoundGeometricObject(composite_bezier)
def get_containing_layer(obj: inkex.BaseElement) -> inkex.Layer:
try:
return obj.ancestors().filter(inkex.Layer)[0]
except IndexError:
raise LayerError(f"Object '{obj.get_id()}' is not inside a layer.")
class LayerError(RuntimeError):
pass
if __name__ == "__main__":
Raytracing().run()

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Set Lens Material</name>
<id>fablabchemnitz.de.raytracing.set_lens_material</id>
<param name="optical_material" type="optiongroup" appearance="combo" gui-text="Select material:">
<option value="none">None</option>
<option value="beam">Beam</option>
<option value="mirror">Mirror</option>
<option value="beam_dump">Beam dump</option>
<option value="beam_splitter">Beam splitter</option>
<option value="glass">Glass</option>
</param>
<param name="optical_index" type="float" min="1.0000" max="3.0000" precision="4" gui-text="Optical index:" indent="2">1.5168</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="FabLab Chemnitz">
<submenu name="Ray Tracing"/>
</submenu>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">set_material.py</command>
</script>
</inkscape-extension>

Some files were not shown because too many files have changed in this diff Show More