Adding back more extensions
This commit is contained in:
parent
8f112e2867
commit
41a3592340
72
extensions/fablabchemnitz/cleanup_styles/cleanup_styles.inx
Normal file
72
extensions/fablabchemnitz/cleanup_styles/cleanup_styles.inx
Normal 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>
|
258
extensions/fablabchemnitz/cleanup_styles/cleanup_styles.py
Normal file
258
extensions/fablabchemnitz/cleanup_styles/cleanup_styles.py
Normal 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()
|
21
extensions/fablabchemnitz/cleanup_styles/meta.json
Normal file
21
extensions/fablabchemnitz/cleanup_styles/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
1
extensions/fablabchemnitz/color_harmony/.gitignore
vendored
Normal file
1
extensions/fablabchemnitz/color_harmony/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*__pycache__*
|
87
extensions/fablabchemnitz/color_harmony/color_harmony.inx
Normal file
87
extensions/fablabchemnitz/color_harmony/color_harmony.inx
Normal 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>
|
318
extensions/fablabchemnitz/color_harmony/color_harmony.py
Normal file
318
extensions/fablabchemnitz/color_harmony/color_harmony.py
Normal 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()
|
@ -0,0 +1 @@
|
||||
# empty
|
@ -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 0≤r≤1, 0≤g≤1, 0≤b≤1.
|
||||
:rtype: tuple (h, c, y) where 0≤h<1, but 0≤c≤2 and 0≤y≤1.
|
||||
|
||||
"""
|
||||
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 0≤h<1, but 0≤c≤2 and 0≤y≤1.
|
||||
: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
|
139
extensions/fablabchemnitz/color_harmony/color_harmony/export.py
Normal file
139
extensions/fablabchemnitz/color_harmony/color_harmony/export.py
Normal 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
|
@ -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]]
|
@ -0,0 +1 @@
|
||||
# empty
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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)
|
@ -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
|
||||
|
125
extensions/fablabchemnitz/color_harmony/color_harmony/shades.py
Normal file
125
extensions/fablabchemnitz/color_harmony/color_harmony/shades.py
Normal 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)]
|
@ -0,0 +1 @@
|
||||
# empty
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
21
extensions/fablabchemnitz/color_harmony/meta.json
Normal file
21
extensions/fablabchemnitz/color_harmony/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
220
extensions/fablabchemnitz/destructive_clip/destructive_clip.py
Normal file
220
extensions/fablabchemnitz/destructive_clip/destructive_clip.py
Normal 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()
|
21
extensions/fablabchemnitz/destructive_clip/meta.json
Normal file
21
extensions/fablabchemnitz/destructive_clip/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
58
extensions/fablabchemnitz/dimensioning/dimensioning.inx
Normal file
58
extensions/fablabchemnitz/dimensioning/dimensioning.inx
Normal 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>
|
283
extensions/fablabchemnitz/dimensioning/dimensioning.py
Normal file
283
extensions/fablabchemnitz/dimensioning/dimensioning.py
Normal 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()
|
21
extensions/fablabchemnitz/dimensioning/meta.json
Normal file
21
extensions/fablabchemnitz/dimensioning/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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()
|
21
extensions/fablabchemnitz/duplicate_reverse_join/meta.json
Normal file
21
extensions/fablabchemnitz/duplicate_reverse_join/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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()
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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()
|
21
extensions/fablabchemnitz/frame_animation_sequence/meta.json
Normal file
21
extensions/fablabchemnitz/frame_animation_sequence/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
192
extensions/fablabchemnitz/guillotine_plus/guillotine_plus.py
Normal file
192
extensions/fablabchemnitz/guillotine_plus/guillotine_plus.py
Normal 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()
|
21
extensions/fablabchemnitz/guillotine_plus/meta.json
Normal file
21
extensions/fablabchemnitz/guillotine_plus/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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()
|
21
extensions/fablabchemnitz/import_attributes/meta.json
Normal file
21
extensions/fablabchemnitz/import_attributes/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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>
|
@ -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()
|
21
extensions/fablabchemnitz/isometric_projection/meta.json
Normal file
21
extensions/fablabchemnitz/isometric_projection/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
16
extensions/fablabchemnitz/low_poly_2/low_poly_2.inx
Normal file
16
extensions/fablabchemnitz/low_poly_2/low_poly_2.inx
Normal 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>
|
301
extensions/fablabchemnitz/low_poly_2/low_poly_2.py
Normal file
301
extensions/fablabchemnitz/low_poly_2/low_poly_2.py
Normal 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()
|
21
extensions/fablabchemnitz/low_poly_2/meta.json
Normal file
21
extensions/fablabchemnitz/low_poly_2/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
21
extensions/fablabchemnitz/open_in_roland_cutstudio/meta.json
Normal file
21
extensions/fablabchemnitz/open_in_roland_cutstudio/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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")
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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()
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
Cohen–Sutherland 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]
|
@ -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
|
@ -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
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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->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>
|
@ -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()
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
@ -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>
|
@ -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()
|
21
extensions/fablabchemnitz/quick_joint/meta.json
Normal file
21
extensions/fablabchemnitz/quick_joint/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
38
extensions/fablabchemnitz/quick_joint/quick_joint.inx
Normal file
38
extensions/fablabchemnitz/quick_joint/quick_joint.inx
Normal 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>
|
310
extensions/fablabchemnitz/quick_joint/quick_joint.py
Normal file
310
extensions/fablabchemnitz/quick_joint/quick_joint.py
Normal 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()
|
20
extensions/fablabchemnitz/raytracing/desc_parser.py
Normal file
20
extensions/fablabchemnitz/raytracing/desc_parser.py
Normal 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
|
39
extensions/fablabchemnitz/raytracing/lens.inx
Normal file
39
extensions/fablabchemnitz/raytracing/lens.inx
Normal 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>
|
231
extensions/fablabchemnitz/raytracing/lens.py
Normal file
231
extensions/fablabchemnitz/raytracing/lens.py
Normal 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()
|
21
extensions/fablabchemnitz/raytracing/meta.json
Normal file
21
extensions/fablabchemnitz/raytracing/meta.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
@ -0,0 +1,4 @@
|
||||
from .optical_object import *
|
||||
from .ray import *
|
||||
from .vector import *
|
||||
from .world import *
|
@ -0,0 +1,2 @@
|
||||
from .cubic_bezier import CubicBezier
|
||||
from .geometric_object import GeometricObject, CompoundGeometricObject, AABBox
|
@ -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)
|
@ -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)
|
@ -0,0 +1,5 @@
|
||||
from .optic_material import *
|
||||
from .beamdump import *
|
||||
from .mirror import *
|
||||
from .beamsplitter import *
|
||||
from .glass import *
|
@ -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()
|
@ -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]
|
@ -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]
|
@ -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]
|
@ -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
|
@ -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
|
18
extensions/fablabchemnitz/raytracing/raytracing/ray.py
Normal file
18
extensions/fablabchemnitz/raytracing/raytracing/ray.py
Normal 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
|
35
extensions/fablabchemnitz/raytracing/raytracing/shade.py
Normal file
35
extensions/fablabchemnitz/raytracing/raytracing/shade.py
Normal 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
|
75
extensions/fablabchemnitz/raytracing/raytracing/vector.py
Normal file
75
extensions/fablabchemnitz/raytracing/raytracing/vector.py
Normal 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
|
93
extensions/fablabchemnitz/raytracing/raytracing/world.py
Normal file
93
extensions/fablabchemnitz/raytracing/raytracing/world.py
Normal 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
|
17
extensions/fablabchemnitz/raytracing/render.inx
Normal file
17
extensions/fablabchemnitz/raytracing/render.inx
Normal 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>
|
||||
|
289
extensions/fablabchemnitz/raytracing/render.py
Normal file
289
extensions/fablabchemnitz/raytracing/render.py
Normal 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()
|
25
extensions/fablabchemnitz/raytracing/set_material.inx
Normal file
25
extensions/fablabchemnitz/raytracing/set_material.inx
Normal 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
Loading…
Reference in New Issue
Block a user