PySketcher is little library that I maintain. It makes it simple to draw geometric images. Originally written by Hans Petter Langtangen, who sadly passed away in 2016, and I've been keeping it up to date and adding features since 2019.
Right now, PySketcher only supports absolute positioning of shapes. This means that you have to know exactly where you'd like the center of your circle to be and exactly what radius it should have.
I'd like to add a facility for a user to describe the various geometric features and relationships which the shapes have, as well as potentially being able to specify the properties absolutely. So instead of saying "I'd like to have a circle of radius and centered at the point , a user could say "Please create me a circle which is centered where these two lines cross and is tangent to this other line."
This presents some interesting challenges:
- How will the relationships between the shapes be represented.
- Once the relationships are understood, how will we work out where the shapes should go? Can we do this without creating performance issues?
- What should the experience be for the developer coding a diagram when they want to represent relationships in code?
At first glance it may look like the first and last items are the same. In fact, the representation and the developer experience are separate concerns. For example if I wish to specify that two lines are parallel, I am constraining the gradient of the 2nd line to be the same as the first. I don't think there is any argument that the concept of "parallel" is more intuitive, however a gradient equality constraint may make more sense from an implementation perspective.
So over a series of articles, I shall explore each of these challenges. This will hopefully be interesting to some readers, and will also help document my thought processes. That way when in two years I look at my code and think "Why on earth did you to that?" I may have some way of understanding my original justification.
The Problem
In this article let's focus on the problem of how to represent the relationships between shapes internally to PySketcher. This is probably a little backwards, as we'd usually like to consider how the users (or developers in this instance) interact with the library before we look at the internals, but in this instance looking at the internals gives us a quite valuable insight into the art of the possible. And we won't completely ignore the developers - we'll look at the internals whilst considering what potential options that opens up for our API in part 3.
So we'd like for PySketcher to enable us to specify a number of shapes, and for us to then specify how those shapes interact, and for this to be represented somehow internally. We'll leave the problem of working out what the shapes end up looking like to part 2. I think it's useful to think about what we're actually saying when we specify a relationship, and I return to the example of parallelization which I briefly mentioned earlier. If we say that a line is parallel to another line, then we are restricting the values which its gradient can take. This means that we may not know what the gradient of the line is when we create it, but instead we'll need to allow it to be worked out later. Perhaps more subtle is to realise that we may not know what the gradient is when we assign that constraint. If we assert that one line is parallel to another, but don't yet know the gradient of that other line (as we've not yet asserted all the constraints in the diagram), then we will be unable to assign the value of the gradient at assignment time. So we need a means to:
- Represent that a shape has some property or value without knowing what that value might be (yet).
- Be able to assign constraints to that property or value.
- Trigger the calculation of the value on the basis of the constraints.
Constrained Thinking
Let's have a look at how we might implement this. As with all my python articles, where you see a line which says #...
that means I've omitted some code for clarity, and usually that code is code from a previous example. Let's start by defining a class which holds a list of constraints, and has a resolve
method to work out a final value from those constraints. As I mentioned earlier, in this article we won't concern ourselves with how we calculate that value, right now we're just interested in how we might represent objects which might be constrained:
class ConstrainedValue():
"""An object which can be passed around to represent a value."""
def __init__(self):
self._constraints: List[Constraint] = []
def constrain_with(self, constraint):
"""Add a constraint to this objects list of constraints."""
self._constraints.append(constraint)
def resolve(self):
"""Leave to specific implementations to calculate the value on the basis
of constraints."""
raise NotImplementedError("`resolve` has not yet been implemented.")
Let's also think about what a constraint might look like. I'll define an abstract constraint and the implement the simplest example of a constraint - a constraint which ties the object to a fixed value.
from abc import ABC
class Constraint(ABC):
"""Used to restrict that value of a ```
ConstrainedValue
```."""
pass
class FixedValueConstraint(Constraint):
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
This really looks like we're getting somewhere. We have a class which represents a constrainable value, and we can apply constraints to it.
So how might we integrate this with a shape? Let's take the simplest of shapes, a point, and see what we might do. A naive implementation of a Point
with no ConstrainedValue
integration might be:
class Point:
def __init__(x=None, y=None):
self._x = x
self._y = y
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
@property
def y(self):
return self._y
@y.setter
def y(self, value):
self._y = value
To stitch our ConstrainedValue
into this, we need to modify the initializer so that it creates ConstrainedValue
objects; alter the properties so that they add a FixedValueConstraint
to the ConstrainedValue
; and then use those properties to set the values in the initializer if they are provided:
class Point:
def __init__(self, x=None, y=None):
self._x = ConstrainedValue()
self._y = ConstrainedValue()
if x is not None:
self.x = x
if y is not None :
self.y = y
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x.constrain_with(FixedValueConstraint(value))
@property
def y(self):
return self._y
@y.setter
def y(self, value):
self._y.constrain_with(FixedValueConstraint(value))
So now we can create a Point
, assign it some co-ordinates, and have it return a ConstrainedValue
for each of those properties:
p = Point(1,2)
print(type(p.x).__name__)
print(type(p.y).__name__)
ConstrainedValue
ConstrainedValue
Getting Value Back
Right now this constrained value isn't a lot of use as there's no way of finding out what the value is. The details of how we do this in general are for a later article, but let's implement a trivial resolve method in ConstrainedValue
which simply checks if its constrained with a FixedValueConstraint
and if so returns that value, otherwise throws an UnderConstrainedError
. This will allow us to play a little more and really check that what we've built so far will work for us:
class UnderConstrainedError(RuntimeError):
pass
class ConstrainedValue():
"""An object which can be passed around to represent a value."""
def __init__(self):
self._constraints: List[Constraint] = []
def constrain_with(self, constraint):
"""Add a constraint to this objects list of constraints."""
self._constraints.append(constraint)
def resolve(self):
"""Naive implementation to aid testing"""
for constraint in self._constraints:
if isinstance(constraint, FixedValueConstraint):
return constraint.value
raise UnderconstrainedError("Fixed Value has not been provided.")
So now we should be able to ask our Point
what the values are of its properties:
p = Point(1,2)
print(f"p.x is {p.x.resolve()}")
print(f"p.y is {p.y.resolve()}")
Which gives us the output we'd expect:
p.x is 1
p.y is 2
This is all well and good. We can create a point and give it values, but as it stands we cannot constrain one point to "point to" another. If create a Point
p
with some initial values, and then another point q
and then set the x
value of q
to the x
value of p
, then ask q.x
what its value is:
p = Point(1,2)
q = Point()
q.x = p.x
print(f"q.x is {q.x.resolve()}")
then we get a reference to the ConstrainedValue
which is p.x
instead of 1
as we'd probably expect.
q.x is <test_pysketcher_memoization.test_code_150.<locals>.ConstrainedValue object at 0x106375460>
We can call resolve
twice to get the value we want:
p = Point(1,2)
q = Point()
q.x = p.x
print(f"q.x is {q.x.resolve().resolve()}")
q.x is 1
but that is very messy and in complex diagrams we may have to call resolve many times. We could
alter our resolve method to detect if it is about to return a ConstrainedValue
and if it is, call resolve()
again until such time as it gets to a defined value. I feel its neater, however, to detect if we're assigning a ConstrainedValue
. If we are, then instead of adding a FixedValueConstraint
we simply set the property to the provided ConstrainedValue
.
class Point:
#...
@x.setter
def x(self, value):
if isinstance(value, ConstrainedValue):
self._x = value
self._x.constrain_with(FixedValueConstraint(value))
#...
@y.setter
def y(self, value):
if isinstance(value, ConstrainedValue):
self._y = value
self._y.constrain_with(FixedValueConstraint(value))
This now gives us more intuitive behavior:
p = Point(1,2)
q = Point()
q.x = p.x
print(f"q.x is {q.x.resolve()}")
q.x is 1
Great!
Let's get the descriptors in
All is not well and good, however. If we look at our Point
class we have a code which is repeated for the x
and y
properties. Moreover this code will need to be repeated for all the properties on all of our shapes, which is far from ideal. It would be much better if we could somehow encapsulate the assignment logic into the ConstrainedValue
itself and have that behavior shared across all such objects in PySketcher.
It so happens that there is a way to do this. Python provides a mechanism called a Descriptor
which calls a method when a field is set or read. It is a protocol
which means that we don't have to explicitly declare that we are creating a Descriptor
, we just have to comply with its requirements. In this case defining a __set__
, __get__
, or __delete__
method is sufficient.
class ExampleDescriptor:
def __init(self):
self._value = None
def __get__(self, instance, type=None):
print("I am a get method")
return self._value
def __set__(self, instance, value):
print("I am a set method")
self._value = value
class ExampleObject:
prop = ExampleDescriptor()
test = ExampleObject()
print("##")
test.prop = 1
print("##")
print(test.prop)
print("##")
##
I am a set method
##
I am a get method
1
##
So by augmenting out ConstrainedValue
class with a __get__
and __set__
method, we can move the assignment logic out of the properties in the Point
class, and in fact do away with the properties all together.
class ConstrainedValue():
"""An object which can be passed around to represent a value."""
#...
def __init__(self):
self._value = None
self._constraints: List[Constraint] = []
def __get__(self, instance, type=None):
return self
def __set__(self, instance, value):
if isinstance(value, type(self)):
self._value = value
return
self.reset_constraints()
self.constrain_with(FixedValueConstraint(value))
class Point:
x = ConstrainedValue()
y = ConstrainedValue()
def __init__(self, x=None, y=None):
if x is not None:
self.x = x
if y is not None :
self.y = y
p = Point(1,2)
print(f"p.x is {p.x.resolve()}")
q = Point()
q.x = p.x
print(f"q.x is {q.x.resolve()}")
p.x is 1
q.x is 1
Now this is starting to look great! The Point
class has a really clean definition and the interface for PySketcher users is intuitive. As is so often the way, all is not as rosy as things might at first appear. Let us investigate using p
and q
as independent points.
p = Point(1,2)
q = Point(2,3)
print(f"p.x is {p.x.resolve()}")
print(f"q.x is {q.x.resolve()}")
p.x is 2
q.x is 2
Disaster! Both p
and q
have the same value for x
even though we want them to have separate values.
Perhaps a little too constrained
This is because the ConstrainedValue
belongs to the class Point
not to the instances of class Point
, and as such is shared by all the instances of Point
. Alas it has to be this way because that's how decorators work. If you would like to read more about decorators there is an excellent article here!, and that very article discusses our exact problem. To fix it we use the __set_name__
magic method which is called on instance creation, and use that to define a field on the instance in which to store our values. This also means we have to separate the data away from assignment logic so that we can store it in the instance, which we shall do in a ConstraintSet
object. This is a little neater as the code which assigns the values and deals with the method calls is contained in ConstrainedValue
and the code which holds the value and the resolution logic is held in ConstraintSet
; so we have better separation of concerns.
class ConstraintSet:
def __init__(self):
self._constraints = []
def constrain_with(self, constraint):
"""Add a constraint to this objects list of constraints."""
self._constraints.append(constraint)
def reset_constraints(self):
"""Removes the existing constraints from the constraint set"""
self._constraints = []
def resolve(self):
"""Naive implementation to aid testing"""
for constraint in self._constraints:
if isinstance(constraint, FixedValueConstraint):
return constraint.value
raise UnderconstrainedError("Fixed Value has not been provided.")
class ConstrainedValue:
"""An object which can be passed around to represent a value."""
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, instance, typ=None):
# grab the ConstraintSet from the instance
constraint_set = getattr(instance, self.private_name, None)
# If the instance didn't have an initialized ConstraintSet then
# give it one
if constraint_set is None:
constraint_set = ConstraintSet()
setattr(instance, self.private_name, constraint_set)
return constraint_set
def __set__(self, instance, value):
if isinstance(value, ConstraintSet):
setattr(instance, self.private_name, value)
return
constraint_set = self.__get__(instance)
constraint_set.reset_constraints()
constraint_set.constrain_with(FixedValueConstraint(value))
class Point:
x = ConstrainedValue()
y = ConstrainedValue()
def __init__(self, x=None, y=None):
if x is not None:
self.x = x
if y is not None :
self.y = y
And this now works:
p = Point(1,2)
q = Point(2,3)
print(f"p.x is {p.x.resolve()}")
print(f"q.x is {q.x.resolve()}")
p.x is 1
q.x is 2
As does our original logic:
p = Point(1,2)
print(f"p.x is {p.x.resolve()}")
q = Point()
q.x = p.x
print(f"q.x is {q.x.resolve()}")
p.x is 1
q.x is 1
And for the icing on the cake, let's validate that when we change the value of p.x
, the value of q.x
changes with it:
p = Point(1,2)
print(f"p.x is {p.x.resolve()}")
q = Point()
q.x = p.x
print(f"q.x is {q.x.resolve()}")
p.x = 2
print(f"p.x is {p.x.resolve()}")
print(f"q.x is {q.x.resolve()}")
p.x is 1
q.x is 1
p.x is 2
q.x is 2
Making things more explicit
As a little recap, let's review what we've achieved so far. We can create an object which has fields which can have constraints applied to them by creating them as ConstrainedValues
at the class level. This results in a set of constraints being stored in a ConstraintSet
object at the instance level, and constraints can be added using the constrain_with
method. Our fields can either be assigned a value or another ConstraintSet
. Value assignment is implemented by, behind the scenes, applying a FixedValueConstraint
to the field in question having cleared any previously applied constraints. We have validated this by implementing a Point
object with two fields x
and y
. instantiating this twice, linking the x
field of one instance to the other, and validating that when the first is changed, the second changes with it.
Certainly great progress! What might happen if we wanted to reverse engineer this process, however? If we wanted to ask say, q.x
, what it is constrained by? At the moment, if we executed print(q.x)
it would show the same object as p.x
, namely a FixedValueConstraint
. Moreover seeing that q.x
has been constrained by a FixedValueConstraint
may lead a developer to think that a value had been directly assigned to q.x
which is not the case. This may be confusing if we're trying to debug a complex diagram. As such let's create an additional constraint, a LinkedValueConstraint
, which means we can explicitly model that q.x
has been assigned p.x
.
Before we do that let's sort out how ConstraintSets
are displayed. Right now, if we print one directly, we are given a type and a memory location. Let's override the __repr__
method to give us something a little more meaningful. We'll need to do something similar to FixedValueConstraint
as well:
class ConstraintSet:
#...
def __repr__(self):
retval = "ConstraintSet("
if len(self._constraints) == 0:
retval += ")"
return retval
for constraint in self._constraints:
retval += f"\n {constraint}"
retval += "\n)"
return retval
class FixedValueConstraint(Constraint):
#...
def __repr__(self):
return f"{self.__class__.__name__}<{self.value}>"
#...
p = Point(1,2)
print(p.x)
ConstraintSet(
FixedValueConstraint<1>
)
Okay, so on to our new constraint. This LinkedValueConstraint
will store a link to a ConstraintSet
:
class LinkedValueConstraint(Constraint):
def __init__(self, constraint_set):
self._constraint_set = constraint_set
@property
def constraint_set(self):
return self._constraint_set
def __repr__(self):
return f"{self.__class__.__name__}<{self.constraint_set}>"
A quick test:
p = Point(1,2)
c = LinkedValueConstraint(p.x)
print(c)
LinkedValueConstraint<ConstraintSet(
FixedValueConstraint<1>
)>
So we can see now that our LinkedValueConstraint
is linked to a ConstraintSet
which contains a single constraint which is a FixedValueConstraint
fixed at 1. This is helpful, but it would be even more helpful if we could link back to p.x
itself. The output I would like to see is:
LinkedValueConstraint<p.x>
So let's have a look at what we can do with our ConstraintSet
to give it a useful name. The list of all the constraints may well be useful so we'll leave that in __repr__
but we can override __str__
to use the name as an alternative.
We create the ConstraintSet
in the ConstrainedValue
class, and that is aware of the instance to which the ConstraintSet
belongs, so at that point we can pass a name in. Let's update the constructor of ConstraintSet
to take a name, and add the __str__
method:
class ConstraintSet:
def __init__(self, name=""):
self._constraints = []
self._name = name
#...
def __str__(self):
return self._name
And update ConstrainedValue
to provide that name:
class ConstrainedValue:
"""An object which can be passed around to represent a value."""
#...
def __get__(self, instance, typ=None):
# grab the ConstraintSet from the instance
constraint_set = getattr(instance, self.private_name, None)
# If the instance didn't have an initialized ConstraintSet then
# give it one
if constraint_set is None:
constraint_set = ConstraintSet(f"{instance.name}.{self.public_name}")
setattr(instance, self.private_name, constraint_set)
return constraint_set
#...
And finally, as there is no easy way to work out the name of the variable to which an instance has been defined, we'll update the Point
class to optionally take a name at creation time. This is less than ideal, and I may well fix it later, but I don't want us to get distracted away from constraint modelling!
class Point:
x = ConstrainedValue()
y = ConstrainedValue()
def __init__(self, name="", x=None, y=None):
self._name = name
if x is not None:
self.x = x
if y is not None :
self.y = y
@property
def name(self):
return self._name
Check that worked:
p = Point('p',1,2)
print(p.x)
c = LinkedValueConstraint(p.x)
print(c)
p.x
LinkedValueConstraint<p.x>
Now let's stitch this into our __set__
method in the ConstrainedValue
class:
class ConstrainedValue:
"""An object which can be passed around to represent a value."""
#...
def __set__(self, instance, value):
# Grab the ConstraintSet from the instance
constraint_set = self.__get__(instance, None)
constraint_set.reset_constraints()
# if the value we've been asked to assign is a ConstraintSet
# then add a LinkedValueConstraint:
if isinstance(value, ConstraintSet):
constraint_set.constrain_with(LinkedValueConstraint(value))
return
# otherwise use a FixedValueConstraint to constrain to the provided
# value
constraint_set.constrain_with(FixedValueConstraint(value))
#...
Finally let's tweak our resolve
method on the ConstraintSet
so that it understands what a LinkedValueConstraint
is. Remember this method is only a place holder and the real logic of calculating values we'll deal with in a later article.
class ConstraintSet:
def __init__(self, name=""):
self._constraints = []
self._name = name
#...
def resolve(self):
"""Naive implementation to aid testing"""
for constraint in self._constraints:
if isinstance(constraint, FixedValueConstraint):
return constraint.value
if isinstance(constraint, LinkedValueConstraint):
return constraint.constraint_set.resolve()
raise UnderconstrainedError("Fixed Value has not been provided.")
#...
def __str__(self):
return self._name
And now everything should work end to end:
p = Point('p', 1,2)
q = Point('q')
print(f"p.x is {p.x}")
print(f"p.x resolves to {p.x.resolve()}")
q.x = p.x
print(f"q.x is {repr(q.x)}")
print(f"q.x resolves to {q.x.resolve()}")
p.x = 2
print(f"Now p.x has changed, q.x resolves to {q.x.resolve()}")
p.x is p.x
p.x resolves to 1
q.x is ConstraintSet(
LinkedValueConstraint<p.x>
)
q.x resolves to 1
Now p.x has changed, q.x resolves to 2
Are we done? Not just yet. We can constrain a property to another property, which is helpful, but there are scenarios where we'll need to do more than that. We may, for example, wish to constrain a Point
to being on a line, or on the circumference of a circle. To do this, constraining the properties independently isn't sufficient. We need to be able to constrain the Point
(or indeed any shape) and have that constraint, in turn, constrain the properties of that Point
(or shape). This article, is however, long enough. We shall deal with that in part two!
Top comments (0)