Compare commits

..

48 Commits

Author SHA1 Message Date
c51efebf95 Corrected error message 2024-12-05 11:56:44 -05:00
ea1ac6490a Fix write: color 2024-02-26 10:26:16 -05:00
3d681da551 Export other shapes 2024-02-26 10:04:57 -05:00
5864dc5c06 Coarse grain mouse input with lattice 2024-02-26 10:01:50 -05:00
a152af6bac Fix open empty file 2024-02-24 10:44:11 -05:00
121b8fe4b0 2squares 2024-02-22 17:19:52 -05:00
1cd4e0fdc6 Fix lattice_points for disk 2024-02-22 17:08:42 -05:00
a48457de29 Adjust color of voronoi depending on number of neighbors 2024-02-22 16:50:04 -05:00
4a6d2a1758 More visible Voronoi cells 2024-02-22 16:42:57 -05:00
29355191d6 Staircases 2024-02-22 16:41:45 -05:00
7e44418829 voronoi cells for disks 2024-02-22 16:31:10 -05:00
fd61a4620f Compute discrete Voronoi cell 2024-02-21 19:12:07 -05:00
0070094b94 Save lattice in conf file 2024-02-21 17:10:33 -05:00
37f8d181c2 Error and warning messages 2024-02-21 17:01:52 -05:00
5384d9a964 Save color in conf 2024-02-21 16:56:53 -05:00
ff11c1ce84 Set color of next particle 2024-02-21 16:49:48 -05:00
0a8a0e3c53 Save shape and zoom level to conf 2024-02-21 16:00:15 -05:00
b1d56fea04 Set shape from command 2024-02-21 15:30:52 -05:00
955aa8f10a README and license 2023-05-10 15:28:22 -04:00
3b3053fc3a Dependencies in README 2023-05-10 15:20:48 -04:00
5e425be098 markdown in README 2023-05-10 15:11:27 -04:00
dc4aef2119 Add License 2023-05-10 15:08:58 -04:00
2c2b88a6c6 Add README 2023-05-10 15:03:28 -04:00
e5b6019606 Allow square element to be a rectangle 2022-09-28 19:21:13 -04:00
dd5917cd45 read/write disks 2022-09-27 19:13:44 -04:00
c2ebf92a0e disk polyominoes 2022-09-27 19:01:17 -04:00
c13b0f4556 drawing and stroking elements done in elements.py 2022-09-27 18:50:21 -04:00
383cb5c462 circular element (cannot interact with squares) 2022-09-27 18:42:11 -04:00
7a044a1619 Cleanup imports and add file-wide comments 2022-09-23 21:21:38 -04:00
c09600cd10 Split element into their own file 2022-09-23 21:17:53 -04:00
5835c9003c Implement interaction between squares of different sizes 2022-09-23 20:49:58 -04:00
b9be36b4e0 move Square_element.size to painter, and allow for squares to determine their own size 2022-09-23 19:33:05 -04:00
fba87c564a Change Square_lattice to Lattice_square 2022-09-23 14:47:07 -04:00
36f5226107 optional arguments in lattice spec 2022-09-23 14:45:20 -04:00
5d40050580 Set lattice on open 2022-09-23 14:22:44 -04:00
267c5e5c5c cli passing 2022-09-23 13:41:26 -04:00
42e9f60c4e Fix grid drawing 2022-09-23 13:17:35 -04:00
9cb25730eb lattice 2022-09-23 12:58:48 -04:00
88959cb233 Commit TeX style file for exports 2022-09-21 22:34:32 -04:00
ec9f0018f7 Center logical coordinates (so that zooming in and out keeps center invariant) 2022-09-21 17:37:05 -04:00
19838bc2a2 color names in 'set color' command 2022-02-18 18:15:02 -05:00
57fb407dea export to tikz 2022-02-18 17:21:55 -05:00
28355777e1 Rename to src 2022-02-18 16:26:51 -05:00
b4fbc7c7e2 Set reference for relative position 2021-12-20 23:23:11 +01:00
fc50782116 Print position in status bar 2021-12-20 23:14:27 +01:00
1c1e2ad6f7 transparency for grids 2021-12-10 10:55:04 -05:00
f0f090315a replace keydown with textinput in painter 2021-12-06 13:22:34 -05:00
b0d6178271 adjust zoom level 2021-12-06 13:19:39 -05:00
20 changed files with 1936 additions and 459 deletions

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
Jam is visualization software for hard-core lattice particle systems. It is
designed to explore configurations of particles while enforcing that no two
particles overlap.
**Jam is a work in progress and is nowhere near ready for production.**
# Basic usage
Run with
```bash
./src/jam [configuration_file] [-L lattice]
```
* `[configuration_file]` is an optional argument that specifies a file with
a list of particle positions that will be loaded on initial execution.
* `[-L lattice]` is an optional argument that specifies a background grid that
constrains the position of the particles. So far, the `lattice` argument must
be `square` for the square lattice, or, to specify the unit length of the
lattice: `square:<unit_length>`.
# Dependencies
Jam is written in Python 3, and uses Kivy to run the GUI.
# User interface
* `<right-click>`: create a new particle under the mouse pointer (if the
particle fits)
* `<shift><left-click>`: select/unselect multiple particles
* `<left-click>` and drag: move selected particle(s).
* `backspace`: delete selected particle(s)
# Commands
Commands can be executed by typing `:` (similarly to vim).
* `:q`: exit
* `:w [path_to_file]`: Write particle configuration to file. This will not
overwrite existing files; use `:w!` to overwrite.
* `:e <path_to_file>`: edit file.
* `:export <path_to_file>`: Export configuration to LaTeX (using TikZ)
* `:set color <color_spec>`: Set color of selected particles to
`<color_spec>`. The supported format for `<color_spec>` is either `r,g,b`
with `r`, `g`, and `b` in [0,1], or one of
`red|green|blue|brown|lime|orange|pink|purple|teal|violet|cyan|magenta|yellow|olive|black|darkgray|gray|lightgray|white`.
* `:set zoom <zoom_level>`: Scale all particles by `<zoom_level>`.
* `:set grid [on|off]`: Add a visual grid centered on one of the selected
particles. The size of the mesh can be specified by passing
`:set grid <size_of_mesh>`.
# Current developments
So far, Jam supports particles made of combinations of rectangles, but there is
no infrastructure to set the shape of the particle at runtime.
# License
Jam is distributed under the Apache 2.0 License.
Copyright 2021-2023 Ian Jauslin

48
TeX/jam.sty Normal file
View File

@@ -0,0 +1,48 @@
% Copyright 2021-2023 Ian Jauslin
%
% Licensed under the Apache License, Version 2.0 (the "License");
% you may not use this file except in compliance with the License.
% You may obtain a copy of the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS,
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
% See the License for the specific language governing permissions and
% limitations under the License.
% square lattice (width #1, height #2, origin #3, spacing #4)
\def\grid#1#2#3{
\foreach\i in {0,...,#2}{
\draw#3++(0,\i)--++(#1,0);
}
\foreach\i in {0,...,#1}{
\draw#3++(\i,0)--++(0,#2);
}
}
% cross (color #1, position #2)
\def\cross#1#2{
\fill[color=#1]#2++(0.5,0.5)--++(0,1)--++(-1,0)--++(0,-1)--++(-1,0)--++(0,-1)--++(1,0)--++(0,-1)--++(1,0)--++(0,1)--++(1,0)--++(0,1)--++(-1,0);
\draw[color=black]#2++(0.5,0.5)--++(0,1)--++(-1,0)--++(0,-1)--++(-1,0)--++(0,-1)--++(1,0)--++(0,-1)--++(1,0)--++(0,1)--++(1,0)--++(0,1)--++(-1,0);
}
% 3-staircase (color #1, position #2)
\def\staircase#1#2{
\fill[color=#1]#2++(-0.5,-0.5)--++(3,0)--++(0,1)--++(-1,0)--++(0,1)--++(-1,0)--++(0,1)--++(-1,0)--++(0,-3);
\draw[color=black]#2++(-0.5,-0.5)--++(3,0)--++(0,1)--++(-1,0)--++(0,1)--++(-1,0)--++(0,1)--++(-1,0)--++(0,-3);
}
% disk (color #1, position #2)
\def\disk#1#2{
\fill[color=#1]#2circle(2.5);
\draw[color=black]#2circle(2.5);
}
% square (color #1, position #2)
\def\square#1#2{
\fill[color=#1]#2++(-1,-1)--++(0,2)--++(2,0)--++(0,-2)--cycle;
\draw[color=black]#2++(-1,-1)--++(0,2)--++(2,0)--++(0,-2)--cycle;
}

71
jam
View File

@@ -1,71 +0,0 @@
#!/usr/bin/env python3
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.boxlayout import BoxLayout
from kivy.config import Config
import sys
import os.path
from painter import Painter
from status_bar import Status_bar
from command_prompt import Command_prompt
import filecheck
# App class
class Jam_app(App):
# name of .kv file for main interface
kv_file="jam.kv"
def __init__(self,**kwargs):
# the file open for editing
self.openfile=kwargs.get("openfile","")
# readonly mode
self.readonly=False
super(Jam_app,self).__init__()
def build(self):
layout=BoxLayout(orientation="vertical")
# painter
self.painter=Painter(self)
# status bar
self.status_bar=Status_bar(self)
# command prompt
self.command_prompt=Command_prompt(self)
layout.add_widget(self.painter)
layout.add_widget(self.status_bar)
layout.add_widget(self.command_prompt)
# read openfile
if os.path.isfile(self.openfile):
# read the file
self.painter.read(self.openfile)
# set readonly mode
self.readonly=not os.access(self.openfile,os.W_OK)
return layout
# disable red circles on right click
Config.set('input', 'mouse', 'mouse,disable_multitouch')
# do not exit on escape
Config.set('kivy', 'exit_on_escape', 0)
# read cli
openfile=""
if len(sys.argv)==2:
openfile=sys.argv[1]
# check file
(ret,message)=filecheck.check_edit(openfile)
if ret<0:
print(message,file=sys.stderr)
exit(-1)
# run
if __name__ == '__main__':
Jam_app(openfile=openfile).run()

26
jam.kv
View File

@@ -1,26 +0,0 @@
#: kivy 2.0.0
<Status_bar>:
canvas.before:
Color:
rgb: 1,1,1
Rectangle:
pos: self.pos
size: self.size
size_hint_y: None
height: 20
text_size: self.size
markup: True
color: (0,0,0,1)
font_name: "RobotoMono-Regular"
font_size: "15px"
<Command_prompt>:
size_hint_y: None
height: 20
markup: True
text_size: self.size
font_name: "RobotoMono-Regular"
font_size: "15px"

View File

@@ -1,230 +0,0 @@
import math
import sys
from kivy.graphics import Color,Line,Rectangle
from point import Point,l_infinity
from tools import isint_nonzero,sgn,in_interval
# parent class of all polyominos
class Polyomino():
def __init__(self,**kwargs):
# square elements that maje up the polyomino
self.squares=kwargs.get("squares",[])
self.color=kwargs.get("color",(0,0,1))
self.selected=False
# mesh of background grid (no grid for mesh size 0)
self.grid=kwargs.get("grid",0)
# draw function
def draw(self):
# set color
if not self.selected:
Color(*self.color)
else:
(r,g,b)=self.color
# darken selected
Color(r/2,g/2,b/2)
for square in self.squares:
Rectangle(pos=((square.pos.x-0.5)*square.size,(square.pos.y-0.5)*square.size),size=(square.size,square.size))
# draw boundary
self.stroke()
# draw boundary (override for connected polyominos)
def stroke(self):
# white
Color(1,1,1)
for square in self.squares:
Line(points=(
*((square.pos.x-0.5)*square.size,(square.pos.y-0.5)*square.size),
*((square.pos.x-0.5)*square.size,(square.pos.y+0.5)*square.size),
*((square.pos.x+0.5)*square.size,(square.pos.y+0.5)*square.size),
*((square.pos.x+0.5)*square.size,(square.pos.y-0.5)*square.size),
*((square.pos.x-0.5)*square.size,(square.pos.y-0.5)*square.size)
))
# move by delta
def move(self,delta):
for square in self.squares:
square.pos+=delta
# whether x is in the support of the polyomino
def in_support(self,x):
for square in self.squares:
if l_infinity(square.pos-x)<=1/2:
return True
return False
# check whether self interacts with candidate if candidate were moved by offset
def check_interaction(self,candidate,offset):
for square1 in self.squares:
for square2 in candidate.squares:
if square1.check_interaction(square2.pos+offset):
return True
return False
# square
class Square(Polyomino):
def __init__(self,x,y,**kwargs):
super(Square,self).__init__(**kwargs,squares=[Square_element(x,y)])
# cross
class Cross(Polyomino):
def __init__(self,x,y,**kwargs):
super(Cross,self).__init__(**kwargs,squares=[\
Square_element(x,y),\
Square_element(x+1,y),\
Square_element(x-1,y),\
Square_element(x,y+1),\
Square_element(x,y-1)\
])
# redefine stroke to avoid lines between touching squares
def stroke(self):
Color(1,1,1)
Line(points=(
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y-1.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y-1.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x+1.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x+1.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y+1.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y+1.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x-1.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x-1.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
))
# square building block of polyominos
class Square_element():
# size
size=50
def __init__(self,x,y,**kwargs):
self.pos=Point(x,y)
# set position
def setpos(self,x,y):
self.pos.x=x
self.pos.y=y
# check whether a square at pos interacts with square
def check_interaction(self,pos):
return l_infinity(pos-self.pos)<1
# check whether a square at position pos is touching self
def check_touch(self,pos):
# allow for error
if in_interval(l_infinity(pos-self.pos),1-1e-11,1+1e-11):
return True
return False
# find position along a line that comes in contact with the line going through pos in direction v
def move_on_line_to_stick(self,pos,v):
# compute intersections with four lines making up square
if v.x!=0:
if v.y!=0:
intersections=[\
Point(self.pos.x+1,pos.y+v.y/v.x*(self.pos.x+1-pos.x)),\
Point(self.pos.x-1,pos.y+v.y/v.x*(self.pos.x-1-pos.x)),\
Point(pos.x+v.x/v.y*(self.pos.y+1-pos.y),self.pos.y+1),\
Point(pos.x+v.x/v.y*(self.pos.y-1-pos.y),self.pos.y-1)\
]
else:
intersections=[\
Point(self.pos.x+1,pos.y+v.y/v.x*(self.pos.x+1-pos.x)),\
Point(self.pos.x-1,pos.y+v.y/v.x*(self.pos.x-1-pos.x))
]
else:
if v.y!=0:
intersections=[\
Point(pos.x+v.x/v.y*(self.pos.y+1-pos.y),self.pos.y+1),\
Point(pos.x+v.x/v.y*(self.pos.y-1-pos.y),self.pos.y-1)\
]
else:
print("error: move_on_line_to_stick called with v=0, please file a bug report with the developer",file=sys.stderr)
exit(-1)
# compute closest one, on square
closest=None
dist=math.inf
for i in range(0,len(intersections)):
# check that it is on square
if abs(intersections[i].x-self.pos.x)<=1+1e-11 and abs(intersections[i].y-self.pos.y)<=1+1e-11:
if (intersections[i]-pos)**2<dist:
closest=intersections[i]
dist=(intersections[i]-pos)**2
if closest==None:
print("error: cannot move particle at (",pos.x,",",pos.y,") to the boundary of (",self.pos.x,",",self.pos.y,") in direction (",v.x,",",v.y,")",file=sys.stderr)
exit(-1)
# return difference to pos
return closest-pos
# move along edge of square
def move_along(self,delta,pos):
rel=pos-self.pos
# check if the particle is stuck in the x direction
if isint_nonzero(rel.x):
# check y direction
if isint_nonzero(rel.y):
# in corner
if sgn(delta.y)==-sgn(rel.y):
# stuck in x direction
return self.move_stuck_x(delta,pos)
elif sgn(delta.x)==-sgn(rel.x):
# stuck in y direction
return self.move_stuck_y(delta,pos)
# stuck in both directions
return pos
else:
# stuck in x direction
return self.move_stuck_x(delta,pos)
elif isint_nonzero(rel.y):
# stuck in y direction
return self.move_stuck_y(delta,pos)
# this should never happen
else:
print("error: stuck particle has non-integer relative position: (",rel.x,",",rel.y,")",file=sys.stderr)
exit(-1)
# move when stuck in the x direction
def move_stuck_x(self,delta,pos):
# only move in y direction
candidate=Point(0,delta.y)
# do not move past corners
rel=pos.y-self.pos.y
if delta.y>0:
if rel<math.ceil(rel)-1e-11 and delta.y+rel>math.ceil(rel)+1e-11 and math.ceil(rel)!=0:
# stick to corner
candidate.y=math.ceil(rel)+self.pos.y-pos.y
else:
if rel>math.floor(rel)+1e-11 and delta.y+rel<math.floor(rel)-1e-11 and math.floor(rel)!=0:
# stick to corner
candidate.y=math.floor(rel)+self.pos.y-pos.y
return candidate
# move when stuck in the y direction
def move_stuck_y(self,delta,pos):
# onlx move in x direction
candidate=Point(delta.x,0)
# do not move past corners
rel=pos.x-self.pos.x
if delta.x>0:
if rel<math.ceil(rel)-1e-11 and delta.x+rel>math.ceil(rel)+1e-11 and math.ceil(rel)!=0:
# stick to corner
candidate.x=math.ceil(rel)+self.pos.x-pos.x
else:
if rel>math.floor(rel)+1e-11 and delta.x+rel<math.floor(rel)-1e-11 and math.floor(rel)!=0:
# stick to corner
candidate.x=math.floor(rel)+self.pos.x-pos.x
return candidate

202
src/colors.py Normal file
View File

@@ -0,0 +1,202 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# find named color that is closest to rgb values
def closest_color(rgb,names):
name=""
mindist=float('inf')
for color in names:
dist=(rgb[0]-color[1])**2+(rgb[1]-color[2])**2+(rgb[2]-color[3])**2
if dist<mindist:
name=color[0]
mindist=dist
return name
# color names
## This is adapted from the LaTeX xcolor package
## which is licensed under the LaTeX Project Public License v1.3c
## by Dr. Uwe Kern
##
xcolor_names=[#
["red",1,0,0],#
["green",0,1,0],#
["blue",0,0,1],#
["brown",.75,.5,.25],#
["lime",.75,1,0],#
["orange",1,.5,0],#
["pink",1,.75,.75],#
["purple",.75,0,.25],#
["teal",0,.5,.5],#
["violet",.5,0,.5],#
["cyan",0,1,1],#
["magenta",1,0,1],#
["yellow",1,1,0],#
["olive",.5,.5,0],#
["black",0,0,0],#
["darkgray",.25,.25,.25],#
["gray",.5,.5,.5],#
["lightgray",.75,.75,.75],#
["white",1,1,1],#
##
["AliceBlue",.94,.972,1],#
["AntiqueWhite",.98,.92,.844],#
["Aquamarine",.498,1,.83],#
["Azure",.94,1,1],#
["Beige",.96,.96,.864],#
["Bisque",1,.894,.77],#
["Black",0,0,0],#
["BlanchedAlmond",1,.92,.804],#
["Blue",0,0,1],#
["BlueViolet",.54,.17,.888],#
["Brown",.648,.165,.165],#
["BurlyWood",.87,.72,.53],#
["CadetBlue",.372,.62,.628],#
["Chartreuse",.498,1,0],#
["Chocolate",.824,.41,.116],#
["Coral",1,.498,.312],#
["CornflowerBlue",.392,.585,.93],#
["Cornsilk",1,.972,.864],#
["Crimson",.864,.08,.235],#
["Cyan",0,1,1],#
["DarkBlue",0,0,.545],#
["DarkCyan",0,.545,.545],#
["DarkGoldenrod",.72,.525,.044],#
["DarkGray",.664,.664,.664],#
["DarkGreen",0,.392,0],#
["DarkGrey",.664,.664,.664],#
["DarkKhaki",.74,.716,.42],#
["DarkMagenta",.545,0,.545],#
["DarkOliveGreen",.332,.42,.185],#
["DarkOrange",1,.55,0],#
["DarkOrchid",.6,.196,.8],#
["DarkRed",.545,0,0],#
["DarkSalmon",.912,.59,.48],#
["DarkSeaGreen",.56,.736,.56],#
["DarkSlateBlue",.284,.24,.545],#
["DarkSlateGray",.185,.31,.31],#
["DarkSlateGrey",.185,.31,.31],#
["DarkTurquoise",0,.808,.82],#
["DarkViolet",.58,0,.828],#
["DeepPink",1,.08,.576],#
["DeepSkyBlue",0,.75,1],#
["DimGray",.41,.41,.41],#
["DimGrey",.41,.41,.41],#
["DodgerBlue",.116,.565,1],#
["FireBrick",.698,.132,.132],#
["FloralWhite",1,.98,.94],#
["ForestGreen",.132,.545,.132],#
["Gainsboro",.864,.864,.864],#
["GhostWhite",.972,.972,1],#
["Gold",1,.844,0],#
["Goldenrod",.855,.648,.125],#
["Gray",.5,.5,.5],#
["Green",0,.5,0],#
["GreenYellow",.68,1,.185],#
["Grey",.5,.5,.5],#
["Honeydew",.94,1,.94],#
["HotPink",1,.41,.705],#
["IndianRed",.804,.36,.36],#
["Indigo",.294,0,.51],#
["Ivory",1,1,.94],#
["Khaki",.94,.9,.55],#
["Lavender",.9,.9,.98],#
["LavenderBlush",1,.94,.96],#
["LawnGreen",.488,.99,0],#
["LemonChiffon",1,.98,.804],#
["LightBlue",.68,.848,.9],#
["LightCoral",.94,.5,.5],#
["LightCyan",.88,1,1],#
["LightGoldenrod",.933,.867,.51],#
["LightGoldenrodYellow",.98,.98,.824],#
["LightGray",.828,.828,.828],#
["LightGreen",.565,.932,.565],#
["LightGrey",.828,.828,.828],#
["LightPink",1,.712,.756],#
["LightSalmon",1,.628,.48],#
["LightSeaGreen",.125,.698,.668],#
["LightSkyBlue",.53,.808,.98],#
["LightSlateBlue",.518,.44,1],#
["LightSlateGray",.468,.532,.6],#
["LightSlateGrey",.468,.532,.6],#
["LightSteelBlue",.69,.77,.87],#
["LightYellow",1,1,.88],#
["Lime",0,1,0],#
["LimeGreen",.196,.804,.196],#
["Linen",.98,.94,.9],#
["Magenta",1,0,1],#
["Maroon",.5,0,0],#
["MediumAquamarine",.4,.804,.668],#
["MediumBlue",0,0,.804],#
["MediumOrchid",.73,.332,.828],#
["MediumPurple",.576,.44,.86],#
["MediumSeaGreen",.235,.7,.444],#
["MediumSlateBlue",.484,.408,.932],#
["MediumSpringGreen",0,.98,.604],#
["MediumTurquoise",.284,.82,.8],#
["MediumVioletRed",.78,.084,.52],#
["MidnightBlue",.098,.098,.44],#
["MintCream",.96,1,.98],#
["MistyRose",1,.894,.884],#
["Moccasin",1,.894,.71],#
["NavajoWhite",1,.87,.68],#
["Navy",0,0,.5],#
["NavyBlue",0,0,.5],#
["OldLace",.992,.96,.9],#
["Olive",.5,.5,0],#
["OliveDrab",.42,.556,.136],#
["Orange",1,.648,0],#
["OrangeRed",1,.27,0],#
["Orchid",.855,.44,.84],#
["PaleGoldenrod",.932,.91,.668],#
["PaleGreen",.596,.985,.596],#
["PaleTurquoise",.688,.932,.932],#
["PaleVioletRed",.86,.44,.576],#
["PapayaWhip",1,.936,.835],#
["PeachPuff",1,.855,.725],#
["Peru",.804,.52,.248],#
["Pink",1,.752,.796],#
["Plum",.868,.628,.868],#
["PowderBlue",.69,.88,.9],#
["Purple",.5,0,.5],#
["Red",1,0,0],#
["RosyBrown",.736,.56,.56],#
["RoyalBlue",.255,.41,.884],#
["SaddleBrown",.545,.27,.075],#
["Salmon",.98,.5,.448],#
["SandyBrown",.956,.644,.376],#
["SeaGreen",.18,.545,.34],#
["Seashell",1,.96,.932],#
["Sienna",.628,.32,.176],#
["Silver",.752,.752,.752],#
["SkyBlue",.53,.808,.92],#
["SlateBlue",.415,.352,.804],#
["SlateGray",.44,.5,.565],#
["SlateGrey",.44,.5,.565],#
["Snow",1,.98,.98],#
["SpringGreen",0,1,.498],#
["SteelBlue",.275,.51,.705],#
["Tan",.824,.705,.55],#
["Teal",0,.5,.5],#
["Thistle",.848,.75,.848],#
["Tomato",1,.39,.28],#
["Turquoise",.25,.88,.815],#
["Violet",.932,.51,.932],#
["VioletRed",.816,.125,.565],#
["Wheat",.96,.87,.7],#
["White",1,1,1],#
["WhiteSmoke",.96,.96,.96],#
["Yellow",1,1,0],#
["YellowGreen",.604,.804,.196]#
]

View File

@@ -1,3 +1,17 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from kivy.uix.label import Label
from kivy.core.window import Window
from kivy.graphics import Color,Rectangle
@@ -5,6 +19,8 @@ import glob
import os.path
import filecheck
import colors
from polyomino import Cross,Disk,Staircase,Square2
class Command_prompt(Label):
@@ -259,7 +275,7 @@ class Command_prompt(Label):
cursor=self.parse_argv()
# write and edit commands
if self.argv[0]=="w" or self.argv[0]=="e":
if self.argv[0]=="w" or self.argv[0]=="e" or self.argv[0]=="export":
# check that cursor is in first argument
if cursor[0]==1:
# complete filesystem path
@@ -307,6 +323,9 @@ class Command_prompt(Label):
# edit file
if self.argv[0]=="e":
self.run_edit(self.argv)
# export to file
if self.argv[0]=="export":
self.run_export(self.argv)
# quit
if self.argv[0]=="q" or self.argv=="wq":
self.app.stop()
@@ -320,9 +339,17 @@ class Command_prompt(Label):
if argv[1]=="color":
self.run_set_color(argv)
return
elif argv[1]=="shape":
self.run_set_shape(argv)
return
elif argv[1]=="grid":
self.run_set_grid(argv)
return
elif argv[1]=="voronoi":
self.run_set_voronoi(argv)
return
elif argv[1]=="zoom":
self.run_set_zoom(argv)
else:
self.message="error: unrecognized command '"+argv[1]+"'"
return
@@ -330,38 +357,44 @@ class Command_prompt(Label):
# set color
def run_set_color(self,argv):
if len(argv)<3:
self.message="error: 'set color' command was run with without anargument -- usage: 'set color <color_descriptor>'"
self.message="error: 'set color' command was run with without an argument -- usage: 'set color <color_descriptor>'"
return
if argv[2]=="blue":
self.app.painter.set_color((0,0,1))
elif argv[2]=="green":
self.app.painter.set_color((0,1,0))
elif argv[2]=="red":
self.app.painter.set_color((1,0,0))
elif argv[2]=="cyan":
self.app.painter.set_color((0,1,1))
elif argv[2]=="magenta":
self.app.painter.set_color((1,0,1))
elif argv[2]=="yellow":
self.app.painter.set_color((1,1,0))
elif argv[2]=="white":
self.app.painter.set_color((1,1,1))
elif argv[2]=="black":
self.app.painter.set_color((0,0,0))
elif argv[2]=="gray":
self.app.painter.set_color((0.5,0.5,0.5))
# find color name in list
for color in colors.xcolor_names:
if argv[2]==color[0]:
self.app.painter.set_color(color[1:])
return
# numerical rgb values
color_str=argv[2].split(",")
# error if improperly formatted
if len(color_str)!=3:
self.message="error: unrecognized color specification '"+argv[2]+"'; supported format is 'r,g,b' or one of red|green|blue|brown|lime|orange|pink|purple|teal|violet|cyan|magenta|yellow|olive|black|darkgray|gray|lightgray|white or an SVG color name"
return
try:
color=(float(color_str[0]),float(color_str[1]),float(color_str[2]))
except:
self.message="error: unrecognized color specification '"+argv[2]+"'; supported format is 'r,g,b' or one of red|green|blue|brown|lime|orange|pink|purple|teal|violet|cyan|magenta|yellow|olive|black|darkgray|gray|lightgray|white or an SVG color name"
return
self.app.painter.set_color(color)
# set particle shape
def run_set_shape(self,argv):
if len(argv)<3:
self.message="error: 'set shape' command was run with without an argument -- usage: 'set shape <shape_descriptor>'"
return
elif argv[2]=="cross":
self.app.painter.shape=Cross
elif argv[2]=="disk":
self.app.painter.shape=Disk
elif argv[2]=="staircase":
self.app.painter.shape=Staircase
elif argv[2]=="2square":
self.app.painter.shape=Square2
else:
color_str=argv[2].split(",")
# error if improperly formatted
if len(color_str)!=3:
self.message="error: unrecognized color specification '"+argv[2]+"'; supported format is 'r,g,b' or one of red|green|blue|cyan|magenta|yellow|white|black|gray"
return
try:
color=(float(color_str[0]),float(color_str[1]),float(color_str[2]))
except:
self.message="error: unrecognized color specification '"+argv[2]+"'; supported format is 'r,g,b' or one of red|green|blue|cyan|magenta|yellow|white|black|gray"
return
self.app.painter.set_color(color)
self.message="error: unrecognized shape '"+argv[2]+"'; supported shapes are cross|disk|staircase|2square"
return
# toggle grid
def run_set_grid(self,argv):
@@ -383,6 +416,35 @@ class Command_prompt(Label):
return
self.app.painter.set_grid(mesh)
# toggle Voronoi cells
def run_set_voronoi(self,argv):
if len(argv)==2:
# no argument: set to toggle
self.app.painter.set_voronoi(-1)
elif argv[2]=="on":
self.app.painter.set_voronoi(1)
elif argv[2]=="off":
self.app.painter.set_voronoi(0)
else:
self.message="error: unrecognized argument '"+argv[2]+"' -- usage 'set voronoi [on|off]'"
return
# set zoom level (changes size of elements)
def run_set_zoom(self,argv):
if len(argv)==2:
self.message="error: missing argument in 'set zoom'"
return
else:
try:
zoom_level=float(argv[2])
except:
self.message="error: unrecognized argument '"+argv[2]+"' -- usage 'set zoom <zoom_level>'"
return
if zoom_level<0:
self.message="error: zoom level cannot be negative: '"+argv[2]+"'"
return
self.app.painter.set_zoom(zoom_level)
# write to file
@@ -443,3 +505,21 @@ class Command_prompt(Label):
self.app.openfile=argv[1]
# update status bar
self.app.status_bar.draw()
# export to file in Tikz format
def run_export(self,argv):
if len(argv)>2:
self.message="error: could not open file: too many arguments -- usage: ':export <path to file>'"
return
elif len(argv)==1:
self.message="error: could not open file: no argument found -- usage: ':export <path to file>'"
return
# check that the file can be edited
(ret,self.message)=filecheck.check_edit(argv[1])
# error
if ret<0:
return
# export
self.app.painter.export_tikz(argv[1])

297
src/element.py Normal file
View File

@@ -0,0 +1,297 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## elements that polyominoes are made of
import math
import sys
from point import Point,l_infinity,l_2
from tools import isint_nonzero,sgn,in_interval,ceil_grid,floor_grid
from kivy.graphics import Rectangle,Ellipse,Line
# parent class of all elements
class Element():
def __init__(self,x,y,size,**kwargs):
self.pos=Point(x,y)
self.size=size
# set position
def setpos(self,x,y):
self.pos.x=x
self.pos.y=y
# override in each subclass
# draw element
def draw(self,painter):
return
# override in each subclass
# draw boundary
def stroke(self,painter):
return
# override in each subclass
# check whether an element interacts with square
def check_interaction(self,element):
return False
# override in each subclass
# whether x is in the support of the element
def in_support(self,x):
return False
# override in each subclass
# check whether an element is touching self
def check_touch(self,element):
return False
# override in each subclass
# find position along a line that comes in contact with the line going through element.pos in direction v
def move_on_line_to_stick(self,element,v):
return Point(0,0)
# override in each subclass
# move along edge of element
# delta is the impossible move that was asked for
def move_along(self,delta,element):
return element
# rectangular element
# the size of the y component is specified by an aspect ratio: size_x=size, size_y=size*aspect
class Element_square(Element):
def __init__(self,x,y,size,**kwargs):
self.pos=Point(x,y)
self.size=size
self.aspect=kwargs.get("aspect",1.0)
# draw element
def draw(self,painter):
Rectangle(pos=(painter.pos_tocoord_x(self.pos.x-0.5*self.size),painter.pos_tocoord_y(self.pos.y-0.5*self.size*self.aspect)),size=(self.size*painter.base_size,self.size*self.aspect*painter.base_size))
# draw boundary
def stroke(self,painter):
# convert to graphical coordinates
coordx=painter.pos_tocoord_x(square.pos.x)
coordy=painter.pos_tocoord_y(square.pos.y)
Line(points=(
*(coordx-0.5*self.size*painter.base_size,coordy-0.5*self.size*self.aspect*painter.base_size),
*(coordx-0.5*self.size*painter.base_size,coordy+0.5*self.size*self.aspect*painter.base_size),
*(coordx+0.5*self.size*painter.base_size,coordy+0.5*self.size*self.aspect*painter.base_size),
*(coordx+0.5*self.size*painter.base_size,coordy-0.5*self.size*self.aspect*painter.base_size),
*(coordx-0.5*self.size*painter.base_size,coordy-0.5*self.size*self.aspect*painter.base_size)
))
# for use with lattices
# list of lattice points covered by square
def lattice_points(self,lattice):
out=[]
dx=math.floor(0.5*self.size/lattice.spacing)
dy=math.floor(0.5*self.size*self.aspect/lattice.spacing)
for i in range(-dx,dx+1):
for j in range(-dy,dy+1):
out.append(Point(self.pos.x+i*lattice.spacing,self.pos.y+j*lattice.spacing))
return out
# check whether an element interacts with square
# TODO: this only works if element is a rectangle!
def check_interaction(self,element):
# allow for error
return max(abs(element.pos.x-self.pos.x)/(self.size+element.size),abs(element.pos.y-self.pos.y)/(self.size*self.aspect+element.size*element.aspect))<1/2-1e-11
# whether x is in the support of the element
def in_support(self,x):
return max(abs(self.pos.x-x.x),abs(self.pos.y-x.y)/self.aspect)<=1/2
# check whether an element is touching self
# TODO: this only works if element is a rectangle!
def check_touch(self,element):
# allow for error
if in_interval(max(abs(element.pos.x-self.pos.x)/(self.size+element.size),abs(element.pos.y-self.pos.y)/(self.size*self.aspect+element.size*element.aspect)),1/2-1e-11,1/2+1e-11):
return True
return False
# find position along a line that comes in contact with the line going through element.pos in direction v
# TODO: this only works if element is a rectangle!
def move_on_line_to_stick(self,element,v):
size_x=(self.size+element.size)/2
size_y=(self.size*self.aspect+element.size*element.aspect)/2
# compute intersections with four lines making up square
if v.x!=0:
if v.y!=0:
intersections=[\
Point(self.pos.x+size_x,element.pos.y+v.y/v.x*(self.pos.x+size_x-element.pos.x)),\
Point(self.pos.x-size_x,element.pos.y+v.y/v.x*(self.pos.x-size_x-element.pos.x)),\
Point(element.pos.x+v.x/v.y*(self.pos.y+size_y-element.pos.y),self.pos.y+size_y),\
Point(element.pos.x+v.x/v.y*(self.pos.y-size_y-element.pos.y),self.pos.y-size_y)\
]
else:
intersections=[\
Point(self.pos.x+size_x,element.pos.y),\
Point(self.pos.x-size_x,element.pos.y)
]
else:
if v.y!=0:
intersections=[\
Point(element.pos.x,self.pos.y+size_y),\
Point(element.pos.x,self.pos.y-size_y)\
]
else:
print("error: move_on_line_to_stick called with v=0, please file a bug report with the developer",file=sys.stderr)
exit(-1)
# compute closest one, on square
closest=None
dist=math.inf
for i in range(0,len(intersections)):
# check that it is on square
if abs(intersections[i].x-self.pos.x)<=size_x+1e-11 and abs(intersections[i].y-self.pos.y)<=size_y+1e-11:
if (intersections[i]-element.pos)**2<dist:
closest=intersections[i]
dist=(intersections[i]-element.pos)**2
if closest==None:
print("error: cannot move particle at (",element.pos.x,",",element.pos.y,") to the boundary of (",self.pos.x,",",self.pos.y,") in direction (",v.x,",",v.y,")",file=sys.stderr)
exit(-1)
# return difference to pos
return closest-element.pos
# move along edge of square
# TODO: this only works if element is a rectangle!
def move_along(self,delta,element):
size_x=(self.size+element.size)/2
size_y=(self.size*self.aspect+element.size*element.aspect)/2
rel=element.pos-self.pos
# check if the particle is stuck in the x direction
if isint_nonzero(rel.x/size_x):
# check y direction
if isint_nonzero(rel.y/size_y):
# in corner
if sgn(delta.y)==-sgn(rel.y):
# stuck in x direction
return self.move_stuck_x(delta,element)
elif sgn(delta.x)==-sgn(rel.x):
# stuck in y direction
return self.move_stuck_y(delta,element)
# stuck in both directions
return element.pos
else:
# stuck in x direction
return self.move_stuck_x(delta,element)
elif isint_nonzero(rel.y/size_y):
# stuck in y direction
return self.move_stuck_y(delta,element)
# this should never happen
else:
print("error: stuck particle has non-integer relative position: (",rel.x,",",rel.y,")",file=sys.stderr)
exit(-1)
# move when stuck in the x direction
def move_stuck_x(self,delta,element):
size_y=(self.size*self.aspect+element.size*element.aspect)/2
# only move in y direction
candidate=Point(0,delta.y)
# do not move past corners
rel=element.pos.y-self.pos.y
if delta.y>0:
if rel<ceil_grid(rel,size_y)-1e-11 and delta.y+rel>ceil_grid(rel,size_y)+1e-11 and ceil_grid(rel,size_y)!=0:
# stick to corner
candidate.y=ceil_grid(rel,size_y)+self.pos.y-element.pos.y
else:
if rel>floor_grid(rel,size_y)+1e-11 and delta.y+rel<floor_grid(rel,size_y)-1e-11 and floor_grid(rel,size_y)!=0:
# stick to corner
candidate.y=floor_grid(rel,size_y)+self.pos.y-element.pos.y
return candidate
# move when stuck in the y direction
def move_stuck_y(self,delta,element):
size_x=(self.size+element.size)/2
# onlx move in x direction
candidate=Point(delta.x,0)
# do not move past corners
rel=element.pos.x-self.pos.x
if delta.x>0:
if rel<ceil_grid(rel,size_x)-1e-11 and delta.x+rel>ceil_grid(rel,size_x)+1e-11 and ceil_grid(rel,size_x)!=0:
# stick to corner
candidate.x=ceil_grid(rel,size_x)+self.pos.x-element.pos.x
else:
if rel>floor_grid(rel,size_x)+1e-11 and delta.x+rel<floor_grid(rel,size_x)-1e-11 and floor_grid(rel,size_x)!=0:
# stick to corner
candidate.x=floor_grid(rel,size_x)+self.pos.x-element.pos.x
return candidate
# circular elements
# (size is the diameter)
class Element_circle(Element):
# draw element
def draw(self,painter):
Ellipse(pos=(painter.pos_tocoord_x(self.pos.x-0.5*self.size),painter.pos_tocoord_y(self.pos.y-0.5*self.size)),size=(self.size*painter.base_size,self.size*painter.base_size))
# draw boundary
def stroke(self,painter):
Line(circle=(painter.pos_tocoord_x(self.pos.x),painter.pos_tocoord_y(self.pos.y),self.size*0.5*painter.base_size))
# check whether an element interacts with square
# TODO: this only works if element is a circle!
def check_interaction(self,element):
# allow for error
return l_2(element.pos-self.pos)<(self.size+element.size)/2-1e-11
# whether x is in the support of the element
def in_support(self,x):
return l_2(self.pos-x)<=1/2
# check whether an element is touching self
# TODO: this only works if element is a circle!
def check_touch(self,element):
# allow for error
if in_interval(l_2(element.pos-self.pos),(self.size+element.size)/2-1e-11,(self.size+element.size)/2+1e-11):
return True
return False
# find position along a line that comes in contact with the line going through element.pos in direction v
# TODO: this only works if element is a circle!
def move_on_line_to_stick(self,element,v):
# relative position
x=element.pos-self.pos
# radius of collision circle
R=(element.size+self.size)/2
# smallest root of t^2 v^2+2x.v t+x^2-R^2
t=(-v.dot(x)-math.sqrt(v.dot(x)*v.dot(x)-v.dot(v)*(x.dot(x)-R*R)))/v.dot(v)
# return difference to pos
return v*t
# move along edge of circle
# TODO: this only works if element is a circle!
def move_along(self,delta,element):
x=element.pos-self.pos+delta
return x/l_2(x)*(element.size+self.size)/2+self.pos-element.pos
# for use with lattices
# list of lattice points covered by square
def lattice_points(self,lattice):
out=[]
dx=math.floor(0.5*self.size/lattice.spacing)
for i in range(-dx,dx+1):
for j in range(-dx,dx+1):
if lattice.spacing*lattice.spacing*(i*i+j*j)<=self.size*self.size/4:
out.append(Point(self.pos.x+i*lattice.spacing,self.pos.y+j*lattice.spacing))
return out

View File

@@ -1,3 +1,19 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# check that a file is creatable/writable/editable
import os.path
# check that a file can be edited

186
src/jam Executable file
View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
import os.path,os
import filecheck
from lattice import Lattice
## read cli before loading kivy, in case there are errors
# read cli
openfile=""
lattice=""
def read_cli():
global openfile
global lattice
# init flag
flag=""
# loop over arguments
for arg in sys.argv[1:]:
# option flag
if arg[0]=='-':
# loop over options
for c in arg[1:]:
# lattice
if c=='L':
flag="lattice"
else:
print("error: unrecognized option '"+c+"'\n", file=sys.stderr)
exit(-1)
else:
# read lattice argument
if flag=="lattice":
# test the specification
(obj,message)=Lattice.new(arg)
if obj==None:
print(message,file=sys.stderr)
exit(-1)
lattice=arg
# reset flag
flag=""
# no flags
else:
openfile=arg
(ret,message)=filecheck.check_edit(openfile)
if ret<0:
print(message,file=sys.stderr)
exit(-1)
preread_conf(openfile)
# read command line arguments from configuration file
def preread_conf(file):
global lattice
try:
ff=open(file,"r")
except:
return
# counter
i=0
try:
lines=ff.readlines()
except:
print("error: could not read the contents of file '"+file+"'", file=sys.stderr)
exit(-1)
for line in lines:
i+=1
# remove newline
line=line[:len(line)-1]
# ignore comments
if '#' in line:
line=line[:line.find('#')]
# ignore empty lines
if len(line)==0:
continue
# read options
if line[0]=='%':
# ignore empty line
if len(line)==1:
continue
[key,val]=line[1:].split('=',1)
if key=="lattice":
# test the specification
(obj,message)=Lattice.new(val)
if obj==None:
print("error: line "+str(i)+" in file '"+file+"': "+message,file=sys.stderr)
exit(-1)
lattice=val
ff.close()
# read cli
read_cli()
## import kivy
# disable kivy argument parser
os.environ["KIVY_NO_ARGS"] = "1"
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.boxlayout import BoxLayout
from kivy.config import Config
from painter import Painter
from status_bar import Status_bar
from command_prompt import Command_prompt
# App class
class Jam_app(App):
# name of .kv file for main interface
kv_file="jam.kv"
def __init__(self,**kwargs):
# the file open for editing
self.openfile=kwargs.get("openfile","")
# the lattice open for editing
self.lattice=kwargs.get("lattice","")
# readonly mode
self.readonly=False
super(Jam_app,self).__init__()
def build(self):
layout=BoxLayout(orientation="vertical")
# painter
self.painter=Painter(self)
# status bar
self.status_bar=Status_bar(self)
# command prompt
self.command_prompt=Command_prompt(self)
layout.add_widget(self.painter)
layout.add_widget(self.status_bar)
layout.add_widget(self.command_prompt)
# read openfile
if os.path.isfile(self.openfile):
# read the file
self.painter.read(self.openfile)
# set readonly mode
self.readonly=not os.access(self.openfile,os.W_OK)
# load lattice
if self.lattice!="":
(obj,message)=Lattice.new(self.lattice)
self.painter.set_lattice(obj)
return layout
# disable red circles on right click
Config.set('input', 'mouse', 'mouse,disable_multitouch')
# do not exit on escape
Config.set('kivy', 'exit_on_escape', 0)
# run
if __name__ == '__main__':
Jam_app(openfile=openfile,lattice=lattice).run()

40
src/jam.kv Normal file
View File

@@ -0,0 +1,40 @@
#: kivy 2.0.0
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
<Status_bar>:
canvas.before:
Color:
rgb: 1,1,1
Rectangle:
pos: self.pos
size: self.size
size_hint_y: None
height: 20
text_size: self.size
markup: True
color: (0,0,0,1)
font_name: "RobotoMono-Regular"
font_size: "15px"
<Command_prompt>:
size_hint_y: None
height: 20
markup: True
text_size: self.size
font_name: "RobotoMono-Regular"
font_size: "15px"

94
src/lattice.py Normal file
View File

@@ -0,0 +1,94 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# define background lattices
from point import Point
# parent class of all lattices
class Lattice():
def __init__(self,**kwargs):
self.type=kwargs.get("type","")
# lattice point nearest to point
# overwrite in subclasses
def nearest(self,point):
return point
# delta to nearest point
def nearest_delta(self,point):
return self.nearest(point)-point
# draw lattice
# overwrite in subclasses
def draw(self,painter):
return
# return the lattice according to a specification
def new(spec):
specs=spec.split(":")
# check type of lattice
if specs[0]=="square":
return Lattice_square.new_square(specs[1:],spec)
else:
return(None,"error: unrecognized lattice type: '"+specs[0]+"'")
# square lattice
class Lattice_square(Lattice):
def __init__(self,**kwargs):
self.spacing=kwargs.get("spacing",1.)
super(Lattice_square,self).__init__(**kwargs,type="square")
# lattice point nearest to point
def nearest(self,point):
return Point(round(point.x/self.spacing)*self.spacing,round(point.y/self.spacing)*self.spacing)
# draw
def draw(self,painter):
painter.draw_grid(Point(self.spacing/2,self.spacing/2),self.spacing)
# return the lattice according to a specification
def new_square(specs,spec):
# no optional args
if len(specs)==0:
return (Lattice_square(),"")
if len(specs)>1:
return (None,"error: '"+spec+"' is not a valid specification for the square lattice: should be 'square[:spacing]'")
try:
spacing=float(specs[0])
return (Lattice_square(spacing=spacing),"")
except:
return (None,"error: '"+spec+"' is not a valid specification for the square lattice: should be 'square[:spacing]'")
# distance on the lattice between (x1,x2) and (y1,y2)
def distance(self, x1, x2, y1, y2):
return round((abs(x1-y1)+abs(x2-y2))/self.spacing)
# distance between a lattice site and a particle
def distance_to_particle(self, x1, x2, particle):
mindist=self.distance_to_element(x1, x2, particle.elements[0])
for i in range(1,len(particle.elements)):
mindist=min(self.distance_to_element(x1, x2, particle.elements[i]),mindist)
return mindist
# distance between a lattice site and an element
def distance_to_element(self, x1, x2, element):
pts=element.lattice_points(self)
mindist=self.distance(x1, x2, pts[0].x, pts[0].y)
for i in range(1,len(pts)):
mindist=min(self.distance(x1, x2, pts[i].x, pts[i].y),mindist)
return mindist

View File

@@ -1,15 +1,32 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# main drawing class
import sys
import math
from kivy.uix.widget import Widget
from kivy.core.window import Window
from kivy.graphics import Color,Line
from kivy.graphics import Color,Line,Rectangle
from point import Point
from polyomino import Cross
from polyomino import Square_element
from polyomino import Cross,Disk,Staircase,Square2
from tools import remove_fromlist
import colors
# painter class
class Painter(Widget):
@@ -18,6 +35,15 @@ class Painter(Widget):
# list of particles
self.particles=[]
# shape of particle to add next
self.shape=Cross
# color of particle to add next
self.color=(0,0,1)
# underlying lattice
self.lattice=None
# particle under mouse
self.undermouse=None
@@ -29,18 +55,27 @@ class Painter(Widget):
# relative position of mouse when moving
self.offset=Point(0,0)
# reference particle from which positions should be computed
self.reference=None
# app is used to share information between widgets
self.app=app
# modifiers
self.modifiers=[]
# base size for all particles
self.base_size=50
# init Widget
super(Painter,self).__init__(**kwargs)
# init keyboard
self.keyboard = Window.request_keyboard(None,self,"text")
self.keyboard.bind(on_key_down=self.on_key_down,on_key_up=self.on_key_up)
self.keyboard.bind(on_key_down=self.on_key_down,on_key_up=self.on_key_up,on_textinput=self.on_textinput)
# redraw on resize
self.bind(size=lambda obj,value: self.draw())
def reset(self):
@@ -48,39 +83,142 @@ class Painter(Widget):
self.undermouse=None
self.draw()
# set lattice
def set_lattice(self,lattice):
self.lattice=lattice
# draw
self.draw()
# snap all existing particles to grid
for particle in self.particles:
delta=self.lattice.nearest_delta(particle.elements[0].pos)
if not self.check_interaction_any(particle,delta):
particle.move(delta)
# convert logical coordinates (normalized and centered) to the ones that are plotted
def pos_tocoord_x(self,x):
return self.width/2+x*self.base_size
def pos_tocoord_y(self,y):
return self.height/2+y*self.base_size
def coord_topos_x(self,x):
return (x-self.width/2)/self.base_size
def coord_topos_y(self,y):
return (y-self.height/2)/self.base_size
# draw all particles
def draw(self):
self.canvas.clear()
with self.canvas:
# draw order: particles, then grids, then transparent particles
for particle in self.particles:
particle.draw(self)
# draw lattice
if self.lattice!=None:
self.lattice.draw(self)
# draw grids
for particle in self.particles:
if particle.grid>0:
self.draw_grid(particle.squares[0].pos,particle.grid)
self.draw_grid(particle.elements[0].pos,particle.grid)
# draw Voronoi cells
if self.lattice!=None:
for particle in self.particles:
if particle.voronoi>0:
self.draw_voronoi(particle)
for particle in self.particles:
particle.draw()
particle.draw(self,alpha=0.5)
# draw a grid around a particle
def draw_grid(self,pos,mesh):
# height offset due to status bar and command prompt
height_offset=self.app.status_bar.height+self.app.command_prompt.height
# vertical lines
# offest wrt 0
offset=(pos.x-0.5)%mesh
for i in range(math.floor((self.width/Square_element.size-offset)/mesh)+1):
# lines right of pos
xx=pos.x+mesh/2
while self.pos_tocoord_x(xx)<self.width:
Color(1,1,1)
Line(points=((i*mesh+offset)*Square_element.size,height_offset,(i*mesh+offset)*Square_element.size,self.height+height_offset))
# horizontal lines
# offset wrt 0
offset=(pos.y-0.5)%1-height_offset/Square_element.size
for i in range(math.floor((self.height/Square_element.size-offset)/mesh)+1):
Line(points=(self.pos_tocoord_x(xx),height_offset,self.pos_tocoord_x(xx),self.height+height_offset))
xx+=mesh
# lines left of pos
xx=pos.x-mesh/2
while self.pos_tocoord_x(xx)>0:
Color(1,1,1)
Line(points=(0,(i*mesh+offset)*Square_element.size+height_offset,self.width,(i*mesh+offset)*Square_element.size+height_offset))
Line(points=(self.pos_tocoord_x(xx),height_offset,self.pos_tocoord_x(xx),self.height+height_offset))
xx-=mesh
# lines above pos
yy=pos.y+mesh/2
while self.pos_tocoord_y(yy)<self.height:
Color(1,1,1)
Line(points=(0,self.pos_tocoord_y(yy),self.width,self.pos_tocoord_y(yy)))
yy+=mesh
# lines below pos
yy=pos.y-mesh/2
while self.pos_tocoord_y(yy)>0:
Color(1,1,1)
Line(points=(0,self.pos_tocoord_y(yy),self.width,self.pos_tocoord_y(yy)))
yy-=mesh
# draw the discrete Voronoi cell of a particle
def draw_voronoi(self,particle):
# only works for lattices
if self.lattice!=None:
pos=particle.elements[0].pos
# loop over all points
xx=pos.x
while self.pos_tocoord_x(xx)<self.width:
yy=pos.y
while self.pos_tocoord_y(yy)<self.height:
self.draw_voronoi_site(xx,yy,particle.color,self.is_in_voronoi(xx,yy,particle))
yy+=self.lattice.spacing
yy=pos.y-self.lattice.spacing
while self.pos_tocoord_y(yy)>0:
self.draw_voronoi_site(xx,yy,particle.color,self.is_in_voronoi(xx,yy,particle))
yy-=self.lattice.spacing
xx+=self.lattice.spacing
xx=pos.x-self.lattice.spacing
while self.pos_tocoord_x(xx)>0:
yy=pos.y
while self.pos_tocoord_y(yy)<self.height:
self.draw_voronoi_site(xx,yy,particle.color,self.is_in_voronoi(xx,yy,particle))
yy+=self.lattice.spacing
yy=pos.y-self.lattice.spacing
while self.pos_tocoord_y(yy)>0:
self.draw_voronoi_site(xx,yy,particle.color,self.is_in_voronoi(xx,yy,particle))
yy-=self.lattice.spacing
xx-=self.lattice.spacing
# check whether a site is in the Voronoi cell of a particle
def is_in_voronoi(self,x,y,particle):
d_to_particle=self.lattice.distance_to_particle(x,y,particle)
# count how many are in voronoi cell
count=1
# TODO: start with a particle that is close to x,y
for q in self.particles:
dd=self.lattice.distance_to_particle(x,y,q)
if q!=particle and dd<d_to_particle:
return 0
if dd==d_to_particle:
count+=1
return count
# draw a site in a Voronoi cell
def draw_voronoi_site(self,x,y,color,count):
if count==0:
return
Color(color[0],color[1],color[2],1-count*0.1)
Rectangle(pos=(self.pos_tocoord_x(x-0.5*self.lattice.spacing),self.pos_tocoord_y(y-0.5*self.lattice.spacing)),size=(self.base_size*self.lattice.spacing,self.base_size*self.lattice.spacing))
# respond to keyboard
def on_key_down(self, keyboard, keycode, text, modifiers):
# check that command_prompt is not focused
# check the command_prompt is not focused
if not self.app.command_prompt.insert:
if keycode[1]=="shift":
if not 's' in self.modifiers:
@@ -93,8 +231,12 @@ class Painter(Widget):
self.selected=[]
self.draw()
# respond to keyboard (text input is modifier-sensitive, i.e. one can use shift)
def on_textinput(self, window, text):
# check the command_prompt is not focused
if not self.app.command_prompt.insert:
# select all
elif keycode[1]=="a":
if text=="a":
for particle in self.particles:
particle.selected=True
self.selected=self.particles.copy()
@@ -102,7 +244,7 @@ class Painter(Widget):
self.draw()
# toggle grid
elif keycode[1]=="g":
elif text=="g":
for particle in self.selected:
if particle.grid==0:
particle.grid=1
@@ -110,6 +252,25 @@ class Painter(Widget):
particle.grid=-particle.grid
self.draw()
# zoom
elif text=="+":
# increment by 10%
self.set_zoom(self.base_size/50*1.1)
elif text=="-":
# decrease by 10%
self.set_zoom(self.base_size/50*0.9)
elif text=="=":
# reset
self.set_zoom(1)
# set reference
elif text=="r":
if len(self.selected)>0:
self.reference=self.selected[0]
else:
self.reference=None
self.app.status_bar.draw()
def on_key_up(self, keyboard, keycode):
if keycode[1]=="shift":
if 's' in self.modifiers:
@@ -123,10 +284,17 @@ class Painter(Widget):
def on_touch_down(self,touch):
# only respond to touch in drawing area
if self.collide_point(*touch.pos):
# convert to logical
touchx=self.coord_topos_x(touch.x)
touchy=self.coord_topos_y(touch.y)
# create new cross
# create new particle
if touch.button=="right":
new=Cross(touch.x/Square_element.size,touch.y/Square_element.size)
new=self.shape(touchx,touchy,color=self.color)
# snap to lattice
if self.lattice!=None:
new.move(self.lattice.nearest_delta(new.elements[0].pos))
if not self.check_interaction_any(new,Point(0,0)):
# add to list
self.particles.append(new)
@@ -142,11 +310,14 @@ class Painter(Widget):
# select particle
if touch.button=="left":
# find particle under touch
self.undermouse=self.find_particle(Point(touch.x/Square_element.size,touch.y/Square_element.size))
self.undermouse=self.find_particle(Point(touchx,touchy))
# record relative position of click with respect to reference
if self.undermouse!=None:
self.offset=Point(touch.x/Square_element.size,touch.y/Square_element.size)-self.undermouse.squares[0].pos
self.offset=Point(touchx,touchy)-self.undermouse.elements[0].pos
# snap to lattice
if self.lattice!=None:
self.offset=self.lattice.nearest(self.offset)
# no modifiers
if self.modifiers==[]:
@@ -183,21 +354,47 @@ class Painter(Widget):
self.draw()
# draw status bar
self.app.status_bar.draw()
# respond to drag
def on_touch_move(self,touch):
# only respond to touch in drawing area
if self.collide_point(*touch.pos):
# convert to logical
touchc=Point(self.coord_topos_x(touch.x),self.coord_topos_y(touch.y))
# snap to lattice
if self.lattice!=None:
touchc=self.lattice.nearest(touchc)
# only move on left click
if touch.button=="left" and self.modifiers==[] and self.undermouse!=None:
# attempted move determined by the relative position to the relative position of click within self.undermouse
delta=self.adjust_move(Point(touch.x/Square_element.size,touch.y/Square_element.size)-(self.offset+self.undermouse.squares[0].pos),0)
for particle in self.selected:
particle.move(delta)
delta=self.adjust_move(touchc-(self.offset+self.undermouse.elements[0].pos),0)
# snap to lattice
if self.lattice!=None:
delta=self.lattice.nearest(delta)
# check that the move is possible (which is not guaranteed after snapping to lattice)
if not self.check_interaction_unselected_list(self.selected,delta):
for particle in self.selected:
particle.move(delta)
# no lattice, move is guaranteed to be acceptable
else:
for particle in self.selected:
particle.move(delta)
# redraw
self.draw()
# draw status bar
self.app.status_bar.draw()
# find the particle at position pos
def find_particle(self,pos):
@@ -233,9 +430,15 @@ class Painter(Widget):
# check whether a candidate particle element with any of the unselected particles
def check_interaction_unselected_element(self,element,offset):
for particle in self.unselected:
for square in particle.squares:
if square.check_interaction(element.pos+offset):
for elt in particle.elements:
# add offset
element.pos+=offset
if elt.check_interaction(element):
# reset offset
element.pos-=offset
return True
# reset offset
element.pos-=offset
return False
@@ -245,7 +448,7 @@ class Painter(Widget):
# actual_delta is the smallest (componentwise) of all the computed delta's
actual_delta=Point(math.inf,math.inf)
for particle in self.selected:
for element in particle.squares:
for element in particle.elements:
# compute adjustment move due to unselected obstacles
adjusted_delta=self.adjust_move_element(delta,element,0)
# only keep the smallest delta's (in absolute value)
@@ -271,18 +474,23 @@ class Painter(Widget):
# whether newpos is acceptable
accept_newpos=True
for other in self.unselected:
for obstacle in other.squares:
for obstacle in other.elements:
# move would make element overlap with obstacle
if obstacle.check_interaction(element.pos+delta):
element.pos+=delta
if obstacle.check_interaction(element):
element.pos-=delta
accept_newpos=False
# check if particle already touches obstacle
if obstacle.check_touch(element.pos):
if obstacle.check_touch(element):
# move along obstacle while remaining stuck
newdelta=obstacle.move_along(delta,element.pos)
newdelta=obstacle.move_along(delta,element)
else:
newdelta=obstacle.move_on_line_to_stick(element.pos,delta)
newdelta=obstacle.move_on_line_to_stick(element,delta)
if not self.check_interaction_unselected_element(element,newdelta):
return newdelta
else:
# reset offset
element.pos-=delta
if accept_newpos:
return delta
else:
@@ -297,6 +505,9 @@ class Painter(Widget):
# set color of selected particles
def set_color(self,color):
# set color for next particles
self.color=color
# set color of selected particles
for particle in self.selected:
particle.color=color
# redraw
@@ -316,14 +527,46 @@ class Painter(Widget):
# redraw
self.draw()
# set voronoi for selected particles
def set_voronoi(self, onoff):
for particle in self.selected:
if onoff==0:
particle.voronoi=False
elif onoff==1:
particle.voronoi=True
elif onoff==-1:
particle.voronoi=not particle.voronoi
# redraw
self.draw()
# write configuration to file
def write(self,file):
ff=open(file,"w")
# save state (particle shape, zoom, lattice)
if self.shape==Cross:
ff.write("%shape=cross\n")
elif self.shape==Disk:
ff.write("%shape=disk\n")
elif self.shape==Staircase:
ff.write("%shape=staircase\n")
elif self.shape==Square2:
ff.write("%shape=2square\n")
else:
print("bug: unrecognized shape in write: '"+str(self.shape)+"'")
ff.write("%zoom={:1.1f}\n".format(self.base_size/50))
ff.write("%color={:1.1f},{:1.1f},{:1.1f}\n".format(self.color[0],self.color[1],self.color[2]))
if self.lattice != None:
ff.write("%lattice="+self.lattice.type+':'+str(self.lattice.spacing)+"\n")
for particle in self.particles:
if type(particle)==Cross:
ff.write("{:d};".format(CROSS_INDEX))
ff.write("{:05.2f},{:05.2f};{:3.1f},{:3.1f},{:3.1f}\n".format(particle.squares[0].pos.x,particle.squares[0].pos.y,particle.color[0],particle.color[1],particle.color[2]))
elif type(particle)==Disk:
ff.write("{:d};".format(DISK_INDEX))
elif type(particle)==Staircase:
ff.write("{:d};".format(STAIRCASE_INDEX))
elif type(particle)==Square2:
ff.write("{:d};".format(SQUARE2_INDEX))
ff.write("{:05.2f},{:05.2f};{:3.1f},{:3.1f},{:3.1f}\n".format(particle.elements[0].pos.x,particle.elements[0].pos.y,particle.color[0],particle.color[1],particle.color[2]))
ff.close()
# read configuration from file
@@ -357,12 +600,35 @@ class Painter(Widget):
if len(line)==0:
continue
# read options
if line[0]=='%':
# ignore empty line
if len(line)==1:
continue
[key,val]=line[1:].split('=',1)
if key=="shape":
self.app.command_prompt.run_set_shape(["set","shape",val])
elif key=="zoom":
self.app.command_prompt.run_set_zoom(["set","zoom",val])
elif key=="color":
color_str=val.split(',')
try:
self.set_color((float(color_str[0]),float(color_str[1]),float(color_str[2])))
except:
print("warning: ignoring line "+str(i)+" in file '"+file+"': color '"+color_str+"' cannot be read",file=sys.stderr)
# lattice is handled by main function
elif key=="lattice":
continue
else:
print("warning: ignoring line "+str(i)+" in file '"+file+"': unrecognized option '"+key+"'",file=sys.stderr)
continue
entries=line.split(";")
# skip line if improperly formatted
if len(entries)>3:
print("warning: ignoring line "+str(i)+" in file '"+file+"': more than three ';' spearated entries in '"+line+"'",file=sys.stderr)
print("warning: ignoring line "+str(i)+" in file '"+file+"': more than three ';' separated entries in '"+line+"'",file=sys.stderr)
if len(entries)<2:
print("warning: ignoring line "+str(i)+" in file '"+file+"': fewer than two ';' spearated entries in '"+line+"'",file=sys.stderr)
print("warning: ignoring line "+str(i)+" in file '"+file+"': fewer than two ';' separated entries in '"+line+"'",file=sys.stderr)
continue
# position
@@ -400,6 +666,12 @@ class Painter(Widget):
continue
if particle_type==CROSS_INDEX:
candidate=Cross(pos.x,pos.y,color=color)
elif particle_type==DISK_INDEX:
candidate=Disk(pos.x,pos.y,color=color)
elif particle_type==STAIRCASE_INDEX:
candidate=Staircase(pos.x,pos.y,color=color)
elif particle_type==SQUARE2_INDEX:
candidate=Square2(pos.x,pos.y,color=color)
else:
print("warning: ignoring line "+str(i)+" in file '"+file+"': unrecognized particle type: '"+entries[0]+"'",file=sys.stderr)
continue
@@ -414,8 +686,52 @@ class Painter(Widget):
ff.close()
self.draw()
# export to tikz
def export_tikz(self,file):
# abort if no particles
if len(self.particles)==0:
return
ff=open(file,"w")
# header
ff.write("\documentclass{standalone}\n")
ff.write("\n")
ff.write("\\usepackage[svgnames]{xcolor}\n")
ff.write("\\usepackage{tikz}\n")
ff.write("\\usepackage{jam}\n")
ff.write("\n")
ff.write("\\begin{document}\n")
ff.write("\\begin{tikzpicture}\n")
ff.write("\n")
# write position of particles
for particle in self.particles:
if type(particle)==Cross:
ff.write("\cross{"+colors.closest_color(particle.color,colors.xcolor_names)+"}")
elif type(particle)==Disk:
ff.write("\disk{"+colors.closest_color(particle.color,colors.xcolor_names)+"}")
elif type(particle)==Staircase:
ff.write("\staircase{"+colors.closest_color(particle.color,colors.xcolor_names)+"}")
elif type(particle)==Square2:
ff.write("\square{"+colors.closest_color(particle.color,colors.xcolor_names)+"}")
ff.write("{{({:05.2f},{:05.2f})}};\n".format(particle.elements[0].pos.x-self.particles[0].elements[0].pos.x,particle.elements[0].pos.y-self.particles[0].elements[0].pos.y))
ff.write("\\end{tikzpicture}\n")
ff.write("\\end{document}\n")
ff.write("\n")
ff.close()
# set zoom level
def set_zoom(self,level):
self.base_size=level*50
self.draw()
# global variables (used like precompiler variables)
CROSS_INDEX=1
DISK_INDEX=2
STAIRCASE_INDEX=3
SQUARE2_INDEX=4

View File

@@ -1,3 +1,19 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# two-dimensional point structure
import math
# point in two dimensions
@@ -47,3 +63,7 @@ class Point:
# L infinity norm
def l_infinity(x):
return max(abs(x.x),abs(x.y))
# L 2 norm
def l_2(x):
return math.sqrt(x.x*x.x+x.y*x.y)

179
src/polyomino.py Normal file
View File

@@ -0,0 +1,179 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# a polyomino is a collection of elements, defined in elements.py
from kivy.graphics import Color,Line
from point import l_infinity
from element import Element_square,Element_circle
# parent class of all polyominos
class Polyomino():
def __init__(self,**kwargs):
# elements that make up the polyomino
self.elements=kwargs.get("elements",[])
self.color=kwargs.get("color",(0,0,1))
self.selected=False
# mesh of background grid (no grid for mesh size 0)
self.grid=kwargs.get("grid",0)
# draw Voronoi cell
self.voronoi=kwargs.get("voronoi",False)
# draw function
def draw(self,painter,**kwargs):
alpha=kwargs.get("alpha",1)
# set color
if not self.selected:
Color(*self.color,alpha)
else:
(r,g,b)=self.color
# darken selected
Color(r/2,g/2,b/2,alpha)
for element in self.elements:
element.draw(painter)
# draw boundary
self.stroke(painter)
# draw boundary (override for connected polyominos)
def stroke(self,painter):
# white
Color(1,1,1)
for element in self.elements:
element.stroke(painter)
# move by delta
def move(self,delta):
for element in self.elements:
element.pos+=delta
# whether x is in the support of the polyomino
def in_support(self,x):
for element in self.elements:
if element.in_support(x):
return True
return False
# check whether self interacts with candidate if candidate were moved by offset
def check_interaction(self,candidate,offset):
for element1 in self.elements:
for element2 in candidate.elements:
# add offset
element2.pos+=offset
if element1.check_interaction(element2):
# reset offset
element2.pos-=offset
return True
# reset offset
element2.pos-=offset
return False
# square
class Square(Polyomino):
def __init__(self,x,y,**kwargs):
super(Square,self).__init__(**kwargs,elements=[Element_square(x,y,size=kwargs.get("size",1.0))])
# cross
class Cross(Polyomino):
def __init__(self,x,y,**kwargs):
super(Cross,self).__init__(**kwargs,elements=[\
Element_square(x,y,1,aspect=3),\
Element_square(x+1,y,1),\
Element_square(x-1,y,1)\
])
# redefine stroke to avoid lines between touching elements
def stroke(self,painter):
# convert to graphical coordinates
coordx=painter.pos_tocoord_x(self.elements[0].pos.x)
coordy=painter.pos_tocoord_y(self.elements[0].pos.y)
Color(1,1,1)
Line(points=(
*(coordx-0.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy-1.5*painter.base_size),
*(coordx+0.5*painter.base_size,coordy-1.5*painter.base_size),
*(coordx+0.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx+1.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx+1.5*painter.base_size,coordy+0.5*painter.base_size),
*(coordx+0.5*painter.base_size,coordy+0.5*painter.base_size),
*(coordx+0.5*painter.base_size,coordy+1.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy+1.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy+0.5*painter.base_size),
*(coordx-1.5*painter.base_size,coordy+0.5*painter.base_size),
*(coordx-1.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy-0.5*painter.base_size),
))
# disk
class Disk(Polyomino):
def __init__(self,x,y,**kwargs):
super(Disk,self).__init__(**kwargs,elements=[Element_circle(x,y,size=kwargs.get("size",1.0))])
# 3-staircase
class Staircase(Polyomino):
def __init__(self,x,y,**kwargs):
super(Staircase,self).__init__(**kwargs,elements=[\
Element_square(x,y+1,1,aspect=3),\
Element_square(x+1,y,1),\
Element_square(x+1,y+1,1),\
Element_square(x+2,y,1)\
])
# redefine stroke to avoid lines between touching elements
def stroke(self,painter):
# convert to graphical coordinates
coordx=painter.pos_tocoord_x(self.elements[0].pos.x)
coordy=painter.pos_tocoord_y(self.elements[0].pos.y)
Color(1,1,1)
Line(points=(
*(coordx-0.5*painter.base_size,coordy-1.5*painter.base_size),
*(coordx+2.5*painter.base_size,coordy-1.5*painter.base_size),
*(coordx+2.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx+1.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx+1.5*painter.base_size,coordy+0.5*painter.base_size),
*(coordx+0.5*painter.base_size,coordy+0.5*painter.base_size),
*(coordx+0.5*painter.base_size,coordy+1.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy+1.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy-1.5*painter.base_size),
))
# 2-square
class Square2(Polyomino):
def __init__(self,x,y,**kwargs):
super(Square2,self).__init__(**kwargs,elements=[\
Element_square(x,y,1),\
Element_square(x+1,y,1),\
Element_square(x,y+1,1),\
Element_square(x+1,y+1,1)\
])
# redefine stroke to avoid lines between touching elements
def stroke(self,painter):
# convert to graphical coordinates
coordx=painter.pos_tocoord_x(self.elements[0].pos.x)
coordy=painter.pos_tocoord_y(self.elements[0].pos.y)
Color(1,1,1)
Line(points=(
*(coordx-0.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx+1.5*painter.base_size,coordy-0.5*painter.base_size),
*(coordx+1.5*painter.base_size,coordy+1.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy+1.5*painter.base_size),
*(coordx-0.5*painter.base_size,coordy-0.5*painter.base_size),
))

67
src/status_bar.py Normal file
View File

@@ -0,0 +1,67 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from kivy.uix.label import Label
from kivy.graphics import Color,Rectangle
from kivy.utils import escape_markup
class Status_bar(Label):
def __init__(self,app,**kwargs):
# app is used to share information between widgets
self.app=app
# width of a character
self.char_width=9
# unformatted text
self.raw_text=""
# a one-time message that shows up over the bar
self.message=""
# init Label
super(Status_bar,self).__init__(**kwargs)
self.draw()
def draw(self):
# if message is not empty, draw message instead
if self.message!="":
self.text=self.message[:min(len(self.message),int(self.width/self.char_width))]
self.message=""
return
# list openfile
if self.app.openfile!="":
self.raw_text=self.app.openfile
else:
self.raw_text="[no file]"
# readonly
if self.app.readonly:
self.raw_text+=" [RO]"
# coordinates of selected cross
if len(self.app.painter.selected)>0:
# number of spaces to align right (use 13 characters to print position)
spaces=int(self.width/self.char_width)-len(self.raw_text)-13
if spaces>0:
if self.app.painter.reference==None:
self.raw_text+=" "*spaces+"({:05.2f},{:05.2f})\n".format(self.app.painter.selected[0].elements[0].pos.x,self.app.painter.selected[0].elements[0].pos.y)
else:
self.raw_text+=" "*spaces+"({:05.2f},{:05.2f})\n".format(self.app.painter.selected[0].elements[0].pos.x-self.app.painter.reference.elements[0].pos.x,self.app.painter.selected[0].elements[0].pos.y-self.app.painter.reference.elements[0].pos.y)
# do not wrap
self.text=self.raw_text[:min(len(self.raw_text),int(self.width/self.char_width))]

45
src/tools.py Normal file
View File

@@ -0,0 +1,45 @@
# Copyright 2021-2023 Ian Jauslin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import math
# sign function
def sgn(x):
if x>=0:
return 1
return -1
# check whether a number is an integer (with tolerance)
def isint_nonzero(x):
if abs(x)<1e-11:
return False
return abs(round(x)-x)<1e-11
# check that a number is in an interval
def in_interval(x,a,b):
return x>=a and x<=b
# remove x from list a
def remove_fromlist(a,x):
if x in a:
a[a.index(x)]=a[len(a)-1]
a=a[:len(a)-1]
return a
# snap to a grid: ceiling
def ceil_grid(x,size):
return math.ceil(x/size)*size
# snap to a grid: floor
def floor_grid(x,size):
return math.floor(x/size)*size

View File

@@ -1,43 +0,0 @@
from kivy.uix.label import Label
from kivy.graphics import Color,Rectangle
from kivy.utils import escape_markup
class Status_bar(Label):
def __init__(self,app,**kwargs):
# app is used to share information between widgets
self.app=app
# width of a character
self.char_width=9
# unformatted text
self.raw_text=""
# a one-time message that shows up over the bar
self.message=""
# init Label
super(Status_bar,self).__init__(**kwargs)
self.draw()
def draw(self):
# if message is not empty, draw message instead
if self.message!="":
self.text=self.message[:min(len(self.message),int(self.width/self.char_width))]
self.message=""
return
# list openfile
if self.app.openfile!="":
self.raw_text=self.app.openfile
else:
self.raw_text="[no file]"
# readonly
if self.app.readonly:
self.raw_text+=" [RO]"
# do not wrap
self.text=self.raw_text[:min(len(self.raw_text),int(self.width/self.char_width))]

View File

@@ -1,22 +0,0 @@
# sign function
def sgn(x):
if x>=0:
return 1
return -1
# check whether a number is an integer (with tolerance)
def isint_nonzero(x):
if abs(x)<1e-11:
return False
return abs(round(x)-x)<1e-11
# check that a number is in an interval
def in_interval(x,a,b):
return x>=a and x<=b
# remove x from list a
def remove_fromlist(a,x):
if x in a:
a[a.index(x)]=a[len(a)-1]
a=a[:len(a)-1]
return a