Diffing, Patching, and Three-Way Merging for openNURBS 3D Models
The abstract model defines the fundamental concepts that underlie the 3dmdiff
, 3dmpatch
, and 3dmdiff3
programs in terms of a suite of classes and interfaces. In object-oriented programming, an interface describes a set of behaviors (in the form of methods) without specifying how those behaviors should be carried out. Classes that implement an interface are free to choose the most appropriate means of carrying out the actions it specifies. This ability of different classes to respond to the same method call, or message, with their own set of instructions is known as polymorphism and is one of the pillars of object-oriented programming (Armstrong 2006).
Polymorphism can also be achieved through abstract classes, which provide implementations for some, but not all, of the methods they specify. Abstract classes are generally used to define common routines that can be included in multiple other classes through inheritance. However, unlike a normal parent class, an abstract class forces its subclasses to provide implementations of any methods for which it does not.
The abstract model described here includes both interfaces and abstract classes. Because the Python programming language does not support interfaces, abstract classes are used in place of interfaces in the code for the 3dmdiff
, 3dmpatch
, and 3dmdiff3
programs.
In computer science, a string is a sequence of textual characters. Because 3dmdiff
outputs its deltas in a plain text format, and because 3dmpatch
must be able to read that plain text data, the ability to create, manipulate, and parse strings is a crucial part of their operation.
In the abstract model, a Stringable
object is one that can be converted to and from a string. The Stringable
interface is comprised of two methods:
__str__
, which allows it to be automatically called when a string representation of the object is required.A Value
holds a piece of information retrieved from or that can be assigned to a component property and wraps it in a consistent interface that provides methods for common operations such as checking equality and computing deltas. This consistent interface allows other objects to operate on Value
instances without needing to make special allowances for the varying types of their enclosed values.
A Value
is a Stringable
object, which means that possesses toString
and fromString
methods along with the following:
Value
passed to it is equal to the one it was called on.Value
it was called on can be changed into the one that was passed to it. The returned object is an instance of the Delta
class, which is described in Section 4.1.3.Delta
is returned by its class's diff
method. It is used when parsing openNURBS deltas.The abstract model defines a number of implementations of the Value
interface. A JSONEncodeableValue
is one that uses Python's built-in json
package to convert values to and from their textual representations. BooleanValue
, FloatValue
, IntegerValue
, and StringValue
are subclasses of JSONEncodeableValue
that are used for booleans, floating-point numbers, integers, and strings, respectively.
A RegexParseableValue
is one that uses a regular expression to parse the textual representation of its value. UUIDValue
is a subclass of RegexParseableValue
meant for storing UUIDs.
Finally, the EnumeratedValue
class serves as a base for value types that have only a limited number of acceptable values. It stores those acceptable values in a lookup table along with their textual representations for encoding and decoding.
A Delta
represents a change to the value of a component property. Like a Value
, a Delta
encapsulates the details of that change so that other objects can work with its instances without needing specific knowledge of the type of value that was changed or how the change takes place. A Delta
is a Stringable
object, which means that possesses toString
and fromString
methods along with the following:
Value
by executing the change described in the object it was invoked on with the value that was passed to it.Delta
that performs the opposite change from the one it was called on. This method is used during reverse patch operations.The abstract model defines one implementation of the Delta
interface, named Substitution
, which simply replaces an older value with a newer one.
An Accessor
specifies the means by which values are retrieved from and assigned to component properties. It encapsulates knowledge about how to navigate specific parts of the rhino3dm API in a way that can be leveraged by the rest of the abstract model. The Accessor
interface exposes two methods:
The abstract model defines two main implementations of the Accessor
interface. A FunctionalAccessor
is constructed from two functions, one of which retrieves values from a component property and the other of which assigns values to a component property. A PathAccessor
leverages Python's introspective capabilities, namely the getattr
and setattr
functions, to get and set properties based on their names in the rhino3dm API.
A Property
represents a property of a component. It associates an Accessor
instance and an implementation of Value
with a textual label that is unique within the scope of the property's component. Each Property
instance has the following attributes and methods:
Value
that is produced by the property's getValue
method and expected by its setValue
method.Properties are hashable so that they can be used as keys in a Python dictionary, such as in the PropertyMap
classes described in Section 4.1.6. Instances of Property
are also considered equal to a string containing their name in order to ease lookups.
A PropertyMap
is a mapping (or dictionary, in Python terms) that correlates one or more Property
instances to an associated Value
or Delta
. A PropertyMap
has the following methods:
PropertyMap
to the given component.PropertyMap
that combines the properties listed in other
with those listed in the one it was called on. The merge will not succeed if the two PropertyMap
s have different values or deltas assigned to the same property.PropertyMap
.PropertyMap
that has the opposite meaning of the one it was called on.PropertyMap
to the given output stream.The abstract model defines two concrete implementations of PropertyMap
. A PropertyValueMap
maps properties to Value
instances and is used to describe components that have been added to or deleted from a model. A PropertyDeltaMap
maps properties to Delta
instances and is used to describe components that have been modified, as well as certain modifications to the model itself.
A Table
specifies how to retrieve components from, add them to, and delete them from a model. Its interface specifies the following methods:
older
model, which ones have been added to the given newer
model, and which ones appear in both.A Type
enumerates the properties supported by a kind of object.
Color
, color
, COLOR
, and CoLOr
all resolve to the same property.The abstract model defines two concrete classes that inherit from Type
. A ComponentType
describes a type of component by associating a table and a list of properties with a unique name. It exposes the following properties and methods in addition to the ones described above:
The set of component types supported by a model format are gathered in a ComponentTypeRegistry
which exposes the following methods:
A ModelType
also inherits from Type
, and lists the tables, component types, and properties associated with a model format. It exposes the following properties in addition to those belonging to its parent class:
A ComponentDelta
describes changes to a single model component, and corresponds to a single hunk in the output of a diff operation. It provides the following properties and methods:
ComponentDelta
to the given model.ComponentDelta
from a header line. The class of the returned object depends on the first character of the header.PropertyMap
.ComponentDelta
that has the opposite meaning of the one it was called on.ComponentDelta
that includes both the changes listed in other
as well as those listed in the one it was called on. The merge will not succeed if the two ComponentDelta
s have different values or deltas assigned to the same property.ComponentDelta
that has the opposite meaning of the one it was called on.The abstract model defines three concrete implementations of ComponentDelta
. The ComponentAddition
and ComponentDeletion
classes represent components that have been added to and deleted from the model, respectively. Each contains a reference to a PropertyValueMap
that describes how the component that was added or deleted differs from the default state of a component of its type. The ComponentModification
class represents a component that has been modified between older and newer versions of a model. It contains a reference to a PropertyDeltaMap
that describes how the component was changed.
A ModelDelta
describes the result of diffing two models. It contains a collection of ComponentDelta
instances as well as a list of properties that have changed on the model itself.
ModelDelta
describes at least one change.ModelDelta
to the given model.ModelDelta
that includes both the changes listed in other
as well as those listed in the one it was called on. The merge will not succeed if the two ModelDelta
s have different deltas assigned to the same property or if any of the component deltas in the models are incompatible.ModelDelta
that has the opposite meaning of the one it was called on.ModelDelta
to the given output stream.A Session
encapsulates procedures for communicating abnormal or unexpected situations to the user in a way that does not require the abstract model to be tied to a specific user interface paradigm. Its interface specifies the following methods:
Between the abstract model and the 3dmdiff
, 3dmpatch
, and 3dmdiff3
programs lies an adapter layer that connects the fundamental concepts and algorithms laid out in the abstract model with the openNURBS component and geometry classes provided by the rhino3dm library. Much of this layer consists of relatively straightforward definitions of component types, value types, and properties in terms of the classes and interfaces described in Section 4.1. For example, the Color
property of a geometric object is defined by the expression
Property("Color", value_types.Color, "Attributes.ObjectColor")
where Property
is the class described in section \ref{class:property}, "Color"
is the name of the property that will appear in deltas, values_types.Color
is an implementation of the Value
interface (Section \ref{class:value}) that stores the red, green, blue, and alpha values that comprise a color. The string "Attributes.ObjectColor"
is converted by the Property
constructor into an instance of PathAccessor
(Section \ref{class:accessor}) that links the Property
instance to the ObjectColor
property of a component's Attributes
object.
The adapter layer defines a number of Value
implementations in addition to Color
, including Point3d
for storing points in three-dimensional space, Vector3d
for storing three-dimensional vectors, and Interval
for storing ranges of numbers. It also defines an implementation of Delta
called Transformation
to store and apply the 4×4 matrices described in Section 3.4.
The adapter layer also attends to a number of non-trivial challenges involved in marrying the abstract model to the rhino3dm library. One such challenge involves the dual identification scheme present in openNURBS models in which components are identified both by a UUID and by an index. A component's UUID is stable, but its index may change if other components of the same type are added to or removed from the model. The 3dmdiff
and 3dmdiff3
programs therefore rely on component UUIDs alone in order to prevent changing indexes from increasing the complexity deltas more than necessary. However, certain properties that reference other components, such as the linetype of a layer or the material of a geometric object, do so by storing the referenced component's index. The adapter layer works around these situations by providing a special implementation of Accessor
, called an IndexReferenceAccessor
, that translates between indexes and UUIDs. The Linetype
property of a layer is therefore defined by the expression
Property("Linetype", UUIDValue, IndexReferenceAccessor("LinetypeIndex", tables.LINETYPE_TABLE))
where "LinetypeIndex"
is the name of the attribute on the rhino3dm Layer
object that contains the index of the layer's linetype and tables.LINETYPE_TABLE
is the table in which that index should be looked up to convert it to a UUID.
Another difficulty the adapter layer contends with involves differences in how Python and C++ (the language in which openNURBS is written) handle the movement of data into and out of functions. In C++, data can be passed by value, which means it is copied from one scope (such as the code that calls a function) to another (the code inside the function). It can also be passed by pointer or by reference, in which case the location of the data in memory is shared so that changes to that data that occur in one scope are automatically propagated to the other. Python, on the other hand, uses a simpler system in which all values are passed by reference. The Line
property of the LineCurve
class is one of a few places in the rhino3dm API where these two systems collide, as seen in the following example:
from rhino3d import LineCurve, Point3d
p0 = Point3d(3, 0, 0)
p1 = Point3d(0, 4, 0)
curve = LineCurve(p0, p1)
print(curve.Line.From) # outputs "3.0,0.0,0.0"
curve.Line.From = Point3d(10, 10, 0)
print(curve.Line.From) # still outputs "3.0,0.0,0.0"
Under Python's standard pass-by-reference semantics, one would expect the assignment on line 8 to have changed the start point of the LineCurve
so that print
statement on line 9 would output 10.0,10.0,0.0
. However, the underlying C++ function that serves as the getter for LineCurve.Line
returns its value by reference. Line 8 therefore assigns a new start point to a copy of the line stored within the LineCurve
component rather than the original line. After line 8 has finished executing, the copy is destroyed along with its new start point.
To change the start point of the line contained within the LineCurve
component, one must instead store a copy of that line in a variable, change the start point of the copy, and then assign the copy back to the LineCurve
's Line
attribute as shown below.
line = curve.Line
line.From = Point3d(10, 10, 0)
curve.Line = line
The adapter layer provides an implementation of Accessor
, called ValueObjectAccessor
, which performs this action automatically. The StartPoint
property of a LineCurve
is therefore defined using the expression
Property("StartPoint", value_types.Point3d, ValueObjectAccessor("Geometry.Line", "From"))
where the string "Geometry.Line"
specifies the object that must be copied and reassigned and "From"
indicates the attribute of that property that contains the property's value.
One of the major contributions of this thesis is the development of three programs, named 3dmdiff
, 3dmpatch
, and 3dmdiff3
, that implement diffing, patching, and three-way merging for openNURBS models. The command-line interfaces of these programs are modeled after those provided by the GNU Diffutils package, which furnishes industry-standard open-source tools for comparing and merging plain text files. It is hoped that the similarity of 3dmdiff
, 3dmpatch
, and 3dmdiff3
to the widely-used diff
, patch
, and diff3
programs will foster a sense of familiarity among prospective users and allow the openNURBS-specific tools described in this thesis to more easily integrate into existing workflows.
Like their plain text counterparts, 3dmdiff
and 3dmpatch
make extensive use of the standard streams, a set of three communications channels that provide a way for computer programs to exchange information with their environments. Standard input, also known as stdin, is used to pass information into a program, and in a command line environment, is typically connected to the keyboard. Standard output, abbreviated as stdout, is used to convey the results of a program's execution to the outside world. Data that is written to stdout is typically printed in the command line console. Finally, the standard error (stderr) stream is used for error messages and debugging information that is not part of a program's normal output.
The standard streams can be redirected so that they connect to a file or another program instead of the keyboard or screen. One form of redirection, called a pipe, has already been demonstrated in Section 3.3 with the command ls | wc -l. In that example, the |
character plugs the standard output stream of ls
(which contains a listing of the files and folders in the current directory) into the standard input stream of wc
(which counts the number of lines in a file) to count the number of files and folders in the current directory.
The standard output stream can be redirected to a file using the >
operator. Rather than being piped directly into wc
as in the previous example, the output of ls
could have been written to a file called file_list.txt
using the command ls > file_list.txt. Similarly, the <
operator connects a file to a program's standard input stream. The command wc -l < file_list.txt allows wc
to read the contents file_list.txt
as if they had been typed by the user. Several other forms of stream redirection exist, but the basic patterns shown here are sufficient to understand the operation of the 3dmdiff
and 3dmpatch
programs.
The 3dmdiff
program finds differences between two openNURBS models. and prints an account of those differences to standard output using the delta format described in Section 3.4. Like GNU diff
, it accepts two file system paths as arguments. However, both arguments to 3dmdiff
must reference normal files as the program does not support diffing of directory structures. It is invoked as 3dmdiff [options] fromfile tofile where fromfile
is a path to the openNURBS model that will serve as the origin of the resulting delta and tofile
is a path to the openNURBS model that will serve as its destination. The following options are supported:
Users of 3dmdiff
will frequently want to capture the outputted delta in a file to use later with 3dmpatch
. This can be accomplished by redirecting standard output to a file as in the command 3dmdiff version01.3dm version02.3dm > my_delta.txt which finds the differences between version01.3dm
and version02.3dm
and saves the result in a file called my_delta.txt
.
The 3dmpatch
program applies a delta to an openNURBS model. Typically, 3dmpatch
reads the delta from standard input as follows:
When invoked in this manner, 3dmpatch
attempts to apply the changes in the delta to the first file listed in the delta's preamble. Alternatively, the model to which the delta should be applied can be specified as an argument:
The delta may also be specified as an argument instead of being read from standard input:
3dmpatch [options] originalfile patchfileAll three of these invocation patterns are consistent with the way GNU patch
operates. Additionally, the following options are supported:
The 3dmdiff3
program performs a three-way diff between two openNURBS models that share a common ancestor and optionally applies the resulting delta to the common ancestor to produce a merged model. Unlike GNU diff3
, 3dmdiff3
produces deltas that are meant to be applied to the common ancestor rather than the first argument. It is invoked as 3dmdiff3 [options] myfile oldfile yourfile where myfile
and yourfile
are the paths to the openNURBS models to be merged and oldfile
is the path to their common ancestor. The following options are supported:
Integrating the 3dmdiff
and 3dmdiff3
programs into a Git repository involves defining custom diff and merge drivers and then associating those drivers with the .3dm
file extension (Gitattributes Documentation, n.d). Git does not presently support integration with third-party patch programs such as 3dmpatch
.
The custom diff and merge drivers may be defined in one of several configuration files depending on their desired scope. If the drivers are needed for only one repository, they may be defined in that repository's .git/config
file. If they are required in all of a user's repositories, they may be defined in a file called .gitconfig
in that user's home folder. Finally, if the drivers are required in all repositories belonging to all users of a computer, they may be defined in \$(prefix)/etc/gitconfig
, where \$(prefix)
is the directory into which Git was installed (Git-Config Documentation, n.d).
Git configuration files follow a syntax similar to that of INI files, and custom diff and merge drivers are defined as sections within that syntactical framework. The section for a diff driver begins with a header that contains the word diff followed by an arbitrary quoted name. It contains a single key, command
, that specifies the program to be used for the diff operation. This program receives seven command-line arguments:
The --git
option to 3dmdiff
tells the program to expect those seven arguments in place of the two arguments described in Section 4.3.1. A diff driver for openNURBS models may therefore be defined as:
[diff "opennurbs-diff-driver"]
command = 3dmdiff --git
The section for a merge driver begins with a header that contains the word merge followed by an arbitrary quoted name. It contains up two keys: name
defines a human-readable label for the merge driver and driver
specifies the command to be used for the merge operation. A third key, recursive
, is also supported but is not required for merging openNURBS models.
Git constructs the merge command by replacing special tokens in the text of the driver
setting with information relevant to the merge operation. The token %A
is replaced with a path from which the merge program can read the current branch's version of the file being merged, and %B
is replaced with a path from which it can read the other branch's version. The %O
token is replaced with a path to the common ancestor and %P
is replaced with the path to which the merged file should be saved. Therefore, a merge driver for openNURBS models may be defined as:
[merge "opennurbs-merge-driver"]
name = Merge driver for openNURBS models
driver = 3dmdiff -m -o %P %A %O %B
The final step of integrating 3dmdiff
and 3dmdiff3
with Git is to associate the diff and merge drivers defined above with the .3dm
file extension by adding a line similar to the one below to one of Git's attribute files. Note that the names opennurbs-diff-driver
and opennurbs-merge-driver
correspond to the names assigned to the diff and merge drivers in their respective headers.
*.3dm diff=opennurbs-diff-driver merge=opennurbs-merge-driver
Like Git's configuration files, there are several attributes files that may be used depending on the desired scope of the directives shown above. If the openNURBS diff and merge drivers are needed for only one repository, the line may be added to a file named .gitattributes
in the root of that repository or in its .git/info/attributes
file. If they are required in all repositories on a machine, the line may added to \$(prefix)/etc/gitattributes
, where \$(prefix)
is the directory into which Git was installed.