Writing a Geometric Solver in Python - Part 2: More Modelling

rvodden

Richard Vodden

Posted on September 20, 2021

Writing a Geometric Solver in Python - Part 2: More Modelling

This is the second article in a short series about adding a geometric solver for my PySketcher side project. The basic premise is that instead of having to tell PySketcher exactly where you'd like your shapes to be, you'll be able to specify the relationships the shapes have to each other, and PySketcher will be able to work our where they need to go. Have a read of Part 1 for more details, and for an explanation of how we've built up the model so far.

Recapitulation

We've built a model, which consists of a ConstrainedValue class. This class is a Descriptor and is assigned to classes. When ConstrainedValue is used in a class, that class will store ConstraintSet objects instead of raw values. This means that we can use the constrain_with method of ConstraintSet to indicate that the value of that ConstraintSet is in some way constrained. We built a trivial constraint FixedValueConstraint which indicates that the value ofa ConstraintSet is constrained to, unsurprisingly, a fixed value. We also built aLinkedValueConstraint which indicates that a ConstraintSet is constrained to the value of another ConstraintSet. We implemented a throwaway resolve method on ConstraintSet so that we can test how our model hangs together. Finally we spent some time ensuring that the experience was intuitive for people using the model, and that it was possible to interrogate the model to see how things are stitched together. Whilst we were considering the concepts above we implemented a smallPoint object and used that, with our throwaway revolve method, to illustrate that our modelling code is effective. The full code is available in this gist, and this is an example of it in use:

#... 
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

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()}")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

Top line stuff. We can define an object with some parameters and constrain those parameters using neat little constraints which are tiny standalone objects in themselves.

Drawing a line under it

Next, we'd like to be able to represent more complex relationships. We may, for example, wish to constrain our point to being on a line, or perhaps on the circumference of a circle. In both of these examples the value of xis only known if the value of y is known and visa versa; yet neither can take arbitrary values.Before we consider how we might do that, let's build a simple line object. For the moment we won't worry about using ConstrainedValue in this line, as we wish to constrain the point to the line. We can revisit the Line object later on when we've established some patterns.

class Line:
    def __init__(self, x1, y1, x2, y2):
        self.x1 = x1
        self.y1 = y1
        self.x2 = x2
        self.y2 = y2

    def __repr__(self):
        return f"Line<({self.x1},{self.y1}),({self.x2},{self.y2})>"

l = Line(0,1,2,3)
print(l)
Enter fullscreen mode Exit fullscreen mode
Line<(0,1),(2,3)>
Enter fullscreen mode Exit fullscreen mode

As you can see, we've kept this super simple, and haven't even defined properties for x1, x2etc. It should serve our needs for the moment. Now how might we indicate that our Point is constrained to this line? The answer is surprisingly simple. The Point needs to be able to accept a constraint, and the easiest way to do that is to have it inherit from ConstraintSet:

class Point(ConstraintSet):
    x = ConstrainedValue()
    y = ConstrainedValue()

    def __init__(self, name="", x=None, y=None):
        super().__init__(self)
        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
Enter fullscreen mode Exit fullscreen mode

Here the only changes are the first line of the class, which specifies ConstraintSet as a superclass, and the first line of __init__ which adds the call to the superclass initializer. As we've now inherited from ConstraintSet, our Point class has a constrain_with method which can use to supply a Constraint which indicates that our point should be constrained to a Line. The fancy pants maths people say that if a point is on a line, then it "coincident", so let's use their fancy pants language and define a CoincidentConstraint:

class CoincidentConstraint(Constraint):
    def __init__(self, line):
        self._line = line

    @property
    def line(self):
        return self._line

    def __repr__(self):
        return f"{self.__class__.__name__}<{self.line}>"

l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(repr(p))
Enter fullscreen mode Exit fullscreen mode
ConstraintSet(
    CoincidentConstraint<Line<(1,2),(3,4)>>
)
Enter fullscreen mode Exit fullscreen mode

Crisis of identity

Well our point is now telling us that it's constrained, and is listing out the constraints correctly, but it seems to be having a bit of an identity crisis. It's telling us that it's aConstraintSet, and whilst that is true to a certain extent, it should really tell us that it is aPoint. The issue is because when we originally wrote the __repr__ method in ConstraintSet we hardcoded the class name (tsk). Let's address that:

class ConstraintSet:
#... 

    def __repr__(self):
        retval = f"{self.__class__.__name__}("
        if len(self._constraints) == 0:
            retval += ")"
            return retval

        for constraint in self._constraints:
            retval += f"\n    {constraint}"
        retval += "\n)"
        return retval

#... 

l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(repr(p))
Enter fullscreen mode Exit fullscreen mode
Point(
    CoincidentConstraint<Line<(1,2),(3,4)>>
)
Enter fullscreen mode Exit fullscreen mode

Excellent! Our Point is a Point again, and its correctly reporting its constraints.

Cascading Constraints

What happens if we look at the co-ordinates of the Point?

l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(repr(p.x))
Enter fullscreen mode Exit fullscreen mode
ConstraintSet()
Enter fullscreen mode Exit fullscreen mode

That's not so good. Whilst it is true that we have not directly constrained the x coordinate, it is not true that the x coordinate is not constrained. It would be much better if the xcoordinate could tell us that it was being constrained by the CoincidentConstraint at the Point level.We could just have the constrain_with method apply the CoincidentConstraint to the x and ycoordinates, and this is certainly a valid approach.

However, let's think more broadly than our current scenario for a moment. We may, for example, be constraining a line to be horizontal. A line (or at least our line) is defined by two points, which in turn are each defined by two values, their x and y co-ordinates. Constraining a line to being horizontal is only constraining the y co-ordinates of those two point (constraining them to being equal) and there is no way that our constrain_with method can know this. If we were to just apply the HorizontalConstraint to all the properties then we'd end up with the x co-ordinates showing the that they were constrained, which is misleading.

So we need an alternative approach. It is only really our Constraint implementations which will understand which properties need to be constrained. So we need a mechanism for the constrain_withmethod to give the constraint the means to, in turn, constrain the properties of the object it is constraining. To achieve this we'll use a design pattern called a "callback". We'll define a constrain_callback method on the Constraint which accepts a ConstraintSet object.Then we will update our constrain_with method to call that constrain_callback method. Theconstrain_callback method can then be overridden by the various implementations of Constraint to apply the necessary downstream constraints.

So step 1, we need to modify our Constraint definition to include a constraint_callback method.We face a design decision at this point. We can either declare this method to be an@abstractmethod which will force implementations of Constraint to implement it, or we can provide a default implementation, which will do nothing, meaning that our implementations can just pretend the constraint_callback doesn't exist if they don't wish to constrain properties. It's not particularly clear which direction we should go here. If we provide a default implementation then we risk our users forgetting that it needs to be implemented, and therefore some nasty bugs. If we don't then implementations will have to explicitly state that they want the constraint_callback to do nothing. This is simply a question of style, neither option is particularly better than the other. For pythonic questions of style we should always refer to PEP20 - the zen of python and yet again, in this case, it does not let us down. Rule 2 is "Explicit is better than implicit." so rather than providing a default implementation which implicitly does nothing, we will require our implementations to provide an explicit implementation of the method which does nothing (if that's what they require). In practice this means that we will decorate our method with @abstractmethod:

from abc import ABC, abstractmethod

class Constraint(ABC):
    """Used to restrict that value of a `ConstrainedValue`."""

    @abstractmethod
    def constraint_callback(self, instance):
        raise NotImplementedError("`constraint_callback` must be implemented explicitly.")
Enter fullscreen mode Exit fullscreen mode

And as a direct result of our design decision we need to update our constraints to include a definition of that method. In the case of FixedValueConstraint and LinkedValueConstraint it can simply do nothing. CoincidentConstraint is a touch more complicated so we'll look at that a little later.

class FixedValueConstraint(Constraint):
#... 
    def constraint_callback(self, instance):
        pass

class LinkedValueConstraint(Constraint):
#... 
    def constraint_callback(self, instance):
        pass
Enter fullscreen mode Exit fullscreen mode

Step two is to update our constrain_with method to call the callback:

class ConstraintSet:
#... 

    def constrain_with(self, constraint):
        constraint.constraint_callback(self)
        """Add a constraint to this objects list of constraints."""
        self._constraints.append(constraint)

#... 
Enter fullscreen mode Exit fullscreen mode

Now we need to decide how we're going to actually constrain the parameters. One approach could be to apply the CoincidentConstraint to the x and y. I've thought about this, and I can't see a reason why it technically wouldn't work, however it does create an ambiguity. It would not be clear if the CoincidentConstraint had been applied directly to the x or if it had inherited it from its parent shape. So to avoid this we're going to create an InfluencedConstraint. This new constraint will indicate that the parameter is constrained by something higher up the food chain.

class InfluencedConstraint(Constraint):
    def __init__(self, constraint):
        self._constraint = constraint

    def constraint_callback(self, instance):
        pass

    @property
    def constraint(self):
        return self._constraint

    def __repr__(self):
        return f"{self.__class__.__name__}<{self.constraint}>"
Enter fullscreen mode Exit fullscreen mode

Now we're ready to implement the callback_constraint method on the CoincidentConstraint to have it indicate that it is influencing the two co-ordinates.

class CoincidentConstraint(Constraint):
#... 

    def constraint_callback(self, point):
        point.x.constrain_with(InfluencedConstraint(self))
        point.y.constrain_with(InfluencedConstraint(self))
Enter fullscreen mode Exit fullscreen mode

Let's take this for a spin and see how it goes!

l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(f"p is {repr(p)}")
print(f"p.x is {repr(p.x)}")
Enter fullscreen mode Exit fullscreen mode
p is Point(
    CoincidentConstraint<Line<(1,2),(3,4)>>
)
p.x is ConstraintSet(
    InfluencedConstraint<CoincidentConstraint<Line<(1,2),(3,4)>>>
)
Enter fullscreen mode Exit fullscreen mode

That looks ideal. The Point object is expressing that it is constrained by theCoincidentConstraint and if we look at the x coordinate we can see that it is influenced by theCoincidentConstraint and even see exactly which line the CoincidentConstraint is constraining to. This might get a bit verbose in complex diagrams, but right now its perfect for our needs, so let's leave it like that and worry about verbosity if and when it becomes an issue.

What type of fool do you take me for?

Let's try something a little off the wall. Let's try constraining p.x with aCoincidentConstraint. As CoincidentConstraint is only designed to apply to Point objects, it's likely to do something a little strange when applied to a ConstraintSet:

l = Line(1,2,3,4)
p = Point('p')
p.x.constrain_with(CoincidentConstraint(l))
print(repr(p.x))
Enter fullscreen mode Exit fullscreen mode
AttributeError: 'ConstraintSet' object has no attribute 'x'
Enter fullscreen mode Exit fullscreen mode

Oh dear! This isn't a very helpful error message is it? Whilst in our simple example its probably easy enough to work out what has gone on, more complex scenarios could lead to some real head scratching. To resolve this, let's have the constraint_callback check that the constraint is being applied to a sensible object. In this case it only really makes sense forCoincidentConstraint to be applied to a Point, so let's enforce that. And let's create a customRuntimeError object with a nice error message to tell our users what is going on.


class InvalidConstraintException(RuntimeError):
    """Indicates that a constraint has been applied to an object which doesn't make sense."""

class CoincidentConstraint(Constraint):
#... 

    def constraint_callback(self, point):
        if not isinstance(point, Point):
            raise InvalidConstraintException(f"{self.__class__.__name__} can only"
            f" be applied to `Point`, it cannot be applied to `{point.__class__.__name__}`")
        point.x.constrain_with(InfluencedConstraint(self))
        point.y.constrain_with(InfluencedConstraint(self))
Enter fullscreen mode Exit fullscreen mode

Let's see how that's improved things:

l = Line(1,2,3,4)
p = Point('p')
p.x.constrain_with(CoincidentConstraint(l))
print(repr(p.x))
Enter fullscreen mode Exit fullscreen mode
InvalidConstraintException: CoincidentConstraint can only be applied to `Point`, it cannot be applied to `ConstraintSet`.
Enter fullscreen mode Exit fullscreen mode

This is much better, and likely to save our developers a chunk of time as compared with the old"missing attribute" error message. Spoiler alert though, this specific implementation will come back to bite us in the next section. Comment if you can see why, and no cheating by reading the next section first!

One way or another.

Let's take stock of where we have got to. We have a nice strong framework for expressing geometric constraints as Constraint objects, and we have a clean way of applying these to our geometric objects. We have clear output when we ask the various objects to repr themselves, and we have clear error messages when things don't quite go to plan. There are just a couple of corners we need to clear up before we can call ourselves done. Look at the following example:

p = Point('p')
q = Point('q')

q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
Enter fullscreen mode Exit fullscreen mode
p.x is ConstraintSet()
q.x is ConstraintSet(
    LinkedValueConstraint<p.x>
)
Enter fullscreen mode Exit fullscreen mode

This is a tremendously powerful thing to be able to do. We can create a point and say nothing about it, then refer to it in constraints. What would happen, though, if we were solving our constraint system (the subject of a later article) and q was given a value first? Equality is a reciprocal condition, and when we assert that q.x = p.x we are equally saying that p.x = q.x.We could force this on our users. We could decide that for the purposes of our geometric solver,such operations are not reciprocal, and that users must specify both sides of the relationship:

p = Point('p')
q = Point('q')

q.x = p.x
p.x = q.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
Enter fullscreen mode Exit fullscreen mode
p.x is ConstraintSet(
    LinkedValueConstraint<q.x>
)
q.x is ConstraintSet(
    LinkedValueConstraint<p.x>
)
Enter fullscreen mode Exit fullscreen mode

Whilst this does tick the PEP20 "explicit is better than implicit" rule, rule three of the same says"Simple is better than complex". I'm reasonably sure that every geometric relationship is in someway reciprocal. If l is parallel to m then m is parallel to l. Even with ourCoincidentConstraint if our point is on the line, then our line must go through the point. Let's update our LinkedValueConstraint to apply the reciprocal constraint.

class LinkedValueConstraint(Constraint):
#... 
    def constraint_callback(self, instance):
        if not isinstance(instance, ConstraintSet):
            raise InvalidConstraintException(f"{self.__class__.__name__} can only"
            f" be applied to `ConstraintSet`, it cannot be applied to `{point.__class__.__name__}`")
        self.constraint_set.constrain_with(LinkedValueConstraint(instance))
        pass
Enter fullscreen mode Exit fullscreen mode

And let's try again:

p = Point('p')
q = Point('q')

q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
Enter fullscreen mode Exit fullscreen mode
RecursionError: maximum recursion depth exceeded while calling a Python object
Enter fullscreen mode Exit fullscreen mode

Oops! What's going on here? Well the problem is that the LinkedValueConstraint applies a reciprocal constraint by enforcing a LinkedValueConstraint on the original ConstraintSet which in turn tries to apply a reciprocal constraint etc. And the whole world explodes in a glorious stack overflow (first one of the project though, I'm proud!). We could fix this by adding some kind of flag to constrain_with and constraint_callback to indicate that this is a reciprocal call and that they should not apply the reciprocal constraint as its already done. This is crying out to be forgotten and to lead to some nasty stack overflow type bugs. I think a neater solution is to haveconstrain_with check to see if an identical constraint has already been applied and just exit cleanly if it has. To do this we need to establish what it means for two LinkedValueConstraints to be equal:

class LinkedValueConstraint(Constraint):
#... 
    def __eq__(self, other):
        return isinstance(other, self.__class__) and self.constraint_set == other.constraint_set
Enter fullscreen mode Exit fullscreen mode

Here we've said that a LinkedValueConstraint is only equal to another object, if and only if that other object is also a LinkedValueConstraint and they are both linked to the same ConstraintSet.

p = Point()
l = LinkedValueConstraint(p.x)
m = LinkedValueConstraint(p.x)
n = LinkedValueConstraint(q.x)
print(f"l == m: {l == m}")
print(f"l == n: {l == n}")
Enter fullscreen mode Exit fullscreen mode
l == m: True
l == n: False
Enter fullscreen mode Exit fullscreen mode

And let's stitch this into our constrain_with method:

class ConstraintSet:
#... 

    def constrain_with(self, constraint):
        if constraint in self._constraints:
            return
        constraint.constraint_callback(self)
        """Add a constraint to this objects list of constraints."""
        self._constraints.append(constraint)

#... 
Enter fullscreen mode Exit fullscreen mode

Let's see if that solved our stack overflow:

p = Point('p')
q = Point('q')

q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
Enter fullscreen mode Exit fullscreen mode
RecursionError: maximum recursion depth exceeded
Enter fullscreen mode Exit fullscreen mode

It didn't! Nightmare! The issue here is that we are using the constraint_callback to apply the reciprocal constraint, but that is being called before the constraint is added toself._constraints in the Constraint class. We need it to be that way around because theconstraint_callback is also validating that the object is of a suitable type. Aha! This is our issue. Every time we have to use the work "also" to describe what an object or method is doing, then we likely have ourselves a separation of concerns issue. This was the mistake which I gave you the spoiler alert for earlier! Let's split our constraint_callback into three: a validate_object method, an apply_reciprocal_constraint method, and finally thecascade_constraints.

from abc import ABC, abstractmethod

class Constraint(ABC):
    """Used to restrict that value of a ```

ConstrainedValue

```."""

    @abstractmethod
    def validate_object(self, instance):
        """Validates that `instance` is suitable. Raises `InvalidConstraintException` if not"""
        raise NotImplementedError("`validate_object` must be implemented explicitly.")

    @abstractmethod
    def apply_reciprocal_constraint(self, instance):
        """Applies a matching constraint to the provided instance."""
        raise NotImplementedError("`apply_reciprocal_callback` must be implemented explicitly.")

    @abstractmethod
    def cascade_constraints(self, instance):
        """Applies appropriate constraints to the properties of `instance`."""
        raise NotImplementedError("`cascade_constraints` must be implemented explicitly.")
Enter fullscreen mode Exit fullscreen mode

And let's update our LinkedValueConstraint:

class LinkedValueConstraint(Constraint):
#... 
    def validate_object(self, instance):
        if not isinstance(instance, ConstraintSet):
            raise InvalidConstraintException(f"{self.__class__.__name__} can only"
            f" be applied to `ConstraintSet`, it cannot be applied to `{point.__class__.__name__}`")

    def apply_reciprocal_constraint(self, instance):
        self.constraint_set.constrain_with(LinkedValueConstraint(instance))

    def cascade_constraints(self, instance):
        pass
#... 

Enter fullscreen mode Exit fullscreen mode

And finally sort out ConstraintSet:

class ConstraintSet:
#... 

    def constrain_with(self, constraint):
        constraint.validate_object(self)
        if constraint in self._constraints:
            return
        """Add a constraint to this objects list of constraints."""
        self._constraints.append(constraint)
        constraint.cascade_constraints(self)
        constraint.apply_reciprocal_constraint(self)

#... 
Enter fullscreen mode Exit fullscreen mode

This has had the happy effect of making the constrain_with method much more readable. Let's see if its finally resolved our stack overflow.

p = Point('p')
q = Point('q')

q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
Enter fullscreen mode Exit fullscreen mode
p.x is ConstraintSet(
    LinkedValueConstraint<q.x>
)
q.x is ConstraintSet(
    LinkedValueConstraint<p.x>
)
Enter fullscreen mode Exit fullscreen mode

Hooray! That was a surprising amount of work for the apparently simple task of implementing reciprocal relationships!

Wrapping it all up

As a final flourish we need to update our existing constraints to have these new methods. OurFixedValueConstraint is trivial, we shall just implement each as a no-op:

class FixedValueConstraint(Constraint):
#... 
    def validate_object(self, instance):
        pass

    def apply_reciprocal_constraint(self, instance):
        pass

    def cascade_constraints(self, instance):
        pass
Enter fullscreen mode Exit fullscreen mode

InfluencedConstraint at first feels like it might be a little more complex. Validation is straightforward, it can only apply to another ConstraintSet object, or child object. But what about the reciprocal constraint? In fact, we don't need to do anything here, as the reciprocal object is the "higher up one". If, for example, InfluencedConstraint is being applied to p.x, that's becauseCoincidentConstraint has been applied to p so we already know that the reciprocal constraint isin place, as such we don't need to do anything. Similarly we can make no assumptions as to what constraints will need to be cascaded and we'll leave that to the "higher up" object should that be necessary:

class InfluencedConstraint(Constraint):
    #... 

    def validate_object(self, instance):
        if not isinstance(instance, ConstraintSet):
            raise InvalidConstraintException(f"{self.__class__.__name__} can only"
            f" be applied to `ConstraintSet`, it cannot be applied to `{point.__class__.__name__}`")

    def apply_reciprocal_constraint(self, instance):
        pass

    def cascade_constraints(self, instance):
        pass
Enter fullscreen mode Exit fullscreen mode

We've already done LinkedValueConstraint so that only leaves CoincidentConstraint. In order todo that we'll need to upgrade our Line object to be able to take constraints, and as this article is already about twice as long as I intended, let's leave that for next time! In the next article we'll look at upgrading our Line to be able to accept Constraint objects, and in so doing we'll walk a path towards some pretty powerful generics.

💖 💪 🙅 🚩
rvodden
Richard Vodden

Posted on September 20, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related