From 356ef619e5d822f329527869b02259cd4d19655b Mon Sep 17 00:00:00 2001
From: jan <jan@orange.mpxd.net>
Date: Sun, 31 Mar 2019 14:02:15 -0700
Subject: [PATCH] snapshot 2019-03-31 14:02:15.566630

---
 .gitignore                |   3 +
 LICENSE.md                | 651 ++++++++++++++++++++++++++++++++++++++
 README.md                 |  31 ++
 examples/ellip_grating.py |  28 ++
 masque/__init__.py        |  35 ++
 masque/error.py           |   9 +
 masque/file/__init__.py   |   3 +
 masque/file/gdsii.py      | 380 ++++++++++++++++++++++
 masque/file/svg.py        | 139 ++++++++
 masque/file/utils.py      |  42 +++
 masque/label.py           | 129 ++++++++
 masque/pattern.py         | 538 +++++++++++++++++++++++++++++++
 masque/repetition.py      | 236 ++++++++++++++
 masque/shapes/__init__.py |  12 +
 masque/shapes/arc.py      | 358 +++++++++++++++++++++
 masque/shapes/circle.py   |  99 ++++++
 masque/shapes/ellipse.py  | 166 ++++++++++
 masque/shapes/polygon.py  | 295 +++++++++++++++++
 masque/shapes/shape.py    | 381 ++++++++++++++++++++++
 masque/shapes/text.py     | 224 +++++++++++++
 masque/subpattern.py      | 185 +++++++++++
 masque/utils.py           |  57 ++++
 setup.py                  |  28 ++
 23 files changed, 4029 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 LICENSE.md
 create mode 100644 README.md
 create mode 100644 examples/ellip_grating.py
 create mode 100644 masque/__init__.py
 create mode 100644 masque/error.py
 create mode 100644 masque/file/__init__.py
 create mode 100644 masque/file/gdsii.py
 create mode 100644 masque/file/svg.py
 create mode 100644 masque/file/utils.py
 create mode 100644 masque/label.py
 create mode 100644 masque/pattern.py
 create mode 100644 masque/repetition.py
 create mode 100644 masque/shapes/__init__.py
 create mode 100644 masque/shapes/arc.py
 create mode 100644 masque/shapes/circle.py
 create mode 100644 masque/shapes/ellipse.py
 create mode 100644 masque/shapes/polygon.py
 create mode 100644 masque/shapes/shape.py
 create mode 100644 masque/shapes/text.py
 create mode 100644 masque/subpattern.py
 create mode 100644 masque/utils.py
 create mode 100644 setup.py

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..715503a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.pyc
+__pycache__
+*.idea
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..4ef32f0
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,651 @@
+GNU Affero General Public License
+=================================
+
+_Version 3, 19 November 2007_
+_Copyright © 2007 Free Software Foundation, Inc. &lt;<http://fsf.org/>&gt;_
+
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+## Preamble
+
+The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights
+with two steps: **(1)** assert copyright on the software, and **(2)** offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+## TERMS AND CONDITIONS
+
+### 0. Definitions
+
+“This License” refers to version 3 of the GNU Affero General Public License.
+
+“Copyright” also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+“The Program” refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as “you”.  “Licensees” and
+“recipients” may be individuals or organizations.
+
+To “modify” a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a “modified version” of the
+earlier work or a work “based on” the earlier work.
+
+A “covered work” means either the unmodified Program or a work based
+on the Program.
+
+To “propagate” a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To “convey” a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays “Appropriate Legal Notices”
+to the extent that it includes a convenient and prominently visible
+feature that **(1)** displays an appropriate copyright notice, and **(2)**
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+### 1. Source Code
+
+The “source code” for a work means the preferred form of the work
+for making modifications to it.  “Object code” means any non-source
+form of a work.
+
+A “Standard Interface” means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The “System Libraries” of an executable work include anything, other
+than the work as a whole, that **(a)** is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and **(b)** serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+“Major Component”, in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The “Corresponding Source” for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+The Corresponding Source for a work in source code form is that
+same work.
+
+### 2. Basic Permissions
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+### 3. Protecting Users' Legal Rights From Anti-Circumvention Law
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+### 4. Conveying Verbatim Copies
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+### 5. Conveying Modified Source Versions
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+* **a)** The work must carry prominent notices stating that you modified
+it, and giving a relevant date.
+* **b)** The work must carry prominent notices stating that it is
+released under this License and any conditions added under section 7.
+This requirement modifies the requirement in section 4 to
+“keep intact all notices”.
+* **c)** You must license the entire work, as a whole, under this
+License to anyone who comes into possession of a copy.  This
+License will therefore apply, along with any applicable section 7
+additional terms, to the whole of the work, and all its parts,
+regardless of how they are packaged.  This License gives no
+permission to license the work in any other way, but it does not
+invalidate such permission if you have separately received it.
+* **d)** If the work has interactive user interfaces, each must display
+Appropriate Legal Notices; however, if the Program has interactive
+interfaces that do not display Appropriate Legal Notices, your
+work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+“aggregate” if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+### 6. Conveying Non-Source Forms
+
+You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+* **a)** Convey the object code in, or embodied in, a physical product
+(including a physical distribution medium), accompanied by the
+Corresponding Source fixed on a durable physical medium
+customarily used for software interchange.
+* **b)** Convey the object code in, or embodied in, a physical product
+(including a physical distribution medium), accompanied by a
+written offer, valid for at least three years and valid for as
+long as you offer spare parts or customer support for that product
+model, to give anyone who possesses the object code either **(1)** a
+copy of the Corresponding Source for all the software in the
+product that is covered by this License, on a durable physical
+medium customarily used for software interchange, for a price no
+more than your reasonable cost of physically performing this
+conveying of source, or **(2)** access to copy the
+Corresponding Source from a network server at no charge.
+* **c)** Convey individual copies of the object code with a copy of the
+written offer to provide the Corresponding Source.  This
+alternative is allowed only occasionally and noncommercially, and
+only if you received the object code with such an offer, in accord
+with subsection 6b.
+* **d)** Convey the object code by offering access from a designated
+place (gratis or for a charge), and offer equivalent access to the
+Corresponding Source in the same way through the same place at no
+further charge.  You need not require recipients to copy the
+Corresponding Source along with the object code.  If the place to
+copy the object code is a network server, the Corresponding Source
+may be on a different server (operated by you or a third party)
+that supports equivalent copying facilities, provided you maintain
+clear directions next to the object code saying where to find the
+Corresponding Source.  Regardless of what server hosts the
+Corresponding Source, you remain obligated to ensure that it is
+available for as long as needed to satisfy these requirements.
+* **e)** Convey the object code using peer-to-peer transmission, provided
+you inform other peers where the object code and Corresponding
+Source of the work are being offered to the general public at no
+charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A “User Product” is either **(1)** a “consumer product”, which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or **(2)** anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, “normally used” refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+“Installation Information” for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+### 7. Additional Terms
+
+“Additional permissions” are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+* **a)** Disclaiming warranty or limiting liability differently from the
+terms of sections 15 and 16 of this License; or
+* **b)** Requiring preservation of specified reasonable legal notices or
+author attributions in that material or in the Appropriate Legal
+Notices displayed by works containing it; or
+* **c)** Prohibiting misrepresentation of the origin of that material, or
+requiring that modified versions of such material be marked in
+reasonable ways as different from the original version; or
+* **d)** Limiting the use for publicity purposes of names of licensors or
+authors of the material; or
+* **e)** Declining to grant rights under trademark law for use of some
+trade names, trademarks, or service marks; or
+* **f)** Requiring indemnification of licensors and authors of that
+material by anyone who conveys the material (or modified versions of
+it) with contractual assumptions of liability to the recipient, for
+any liability that these contractual assumptions directly impose on
+those licensors and authors.
+
+All other non-permissive additional terms are considered “further
+restrictions” within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+### 8. Termination
+
+You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated **(a)**
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and **(b)** permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+### 9. Acceptance Not Required for Having Copies
+
+You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+### 10. Automatic Licensing of Downstream Recipients
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+An “entity transaction” is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+### 11. Patents
+
+A “contributor” is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's “contributor version”.
+
+A contributor's “essential patent claims” are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, “control” includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a “patent license” is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To “grant” such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either **(1)** cause the Corresponding Source to be so
+available, or **(2)** arrange to deprive yourself of the benefit of the
+patent license for this particular work, or **(3)** arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  “Knowingly relying” means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is “discriminatory” if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license **(a)** in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or **(b)** primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+### 12. No Surrender of Others' Freedom
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+### 13. Remote Network Interaction; Use with the GNU General Public License
+
+Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+### 14. Revised Versions of this License
+
+The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License “or any later version” applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+### 15. Disclaimer of Warranty
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+### 16. Limitation of Liability
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+### 17. Interpretation of Sections 15 and 16
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+_END OF TERMS AND CONDITIONS_
+
+## How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the “copyright” line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a “Source” link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a “copyright disclaimer” for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+&lt;<http://www.gnu.org/licenses/>&gt;.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6851d70
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# Masque README
+
+Masque is a Python module for designing lithography masks.
+
+The general idea is to implement something resembling the GDSII file-format, but
+with some vectorized element types (eg. circles, not just polygons), better support for
+E-beam doses, and the ability to output to multiple formats.
+
+## Installation
+
+Requirements:
+* python >= 3.5 (written and tested with 3.6)
+* numpy
+* matplotlib (optional, used for visualization functions and text)
+* python-gdsii (optional, used for gdsii i/o)
+* svgwrite (optional, used for svg output)
+* freetype (optional, used for text)
+
+
+Install with pip, via git:
+```bash
+pip install git+https://mpxd.net/code/jan/masque.git@release
+```
+
+## TODO
+* Mirroring
+* Polygon de-embedding
+### Maybe
+* Construct from bitmap
+* Boolean operations on polygons (using pyclipper)
+* Output to OASIS (using fatamorgana)
diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py
new file mode 100644
index 0000000..0a4ce43
--- /dev/null
+++ b/examples/ellip_grating.py
@@ -0,0 +1,28 @@
+# Quick script for testing arcs
+
+import numpy
+
+import masque
+import masque.file.gdsii
+from masque import shapes
+
+
+def main():
+    pat = masque.Pattern(name='ellip_grating')
+    for rmin in numpy.arange(10, 15, 0.5):
+        pat.shapes.append(shapes.Arc(
+            radii=(rmin, rmin),
+            width=0.1,
+            angles=(-numpy.pi/4, numpy.pi/4)
+        ))
+
+    pat.scale_by(1000)
+    pat.visualize()
+    pat2 = pat.copy()
+    pat2.name = 'grating2'
+
+    masque.file.gdsii.write_dose2dtype((pat, pat2), 'out.gds', 1e-9, 1e-3)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/masque/__init__.py b/masque/__init__.py
new file mode 100644
index 0000000..0112287
--- /dev/null
+++ b/masque/__init__.py
@@ -0,0 +1,35 @@
+"""
+ masque 2D CAD library
+
+ masque is an attempt to make a relatively small library for designing lithography
+  masks. The general idea is to implement something resembling the GDSII file-format, but
+  with some vectorized element types (eg. circles, not just polygons), better support for
+  E-beam doses, and the ability to output to multiple formats.
+
+ Pattern is a basic object containing a 2D lithography mask, composed of a list of Shape
+  objects and a list of SubPattern objects.
+
+ SubPattern provides basic support for nesting Pattern objects within each other, by adding
+  offset, rotation, scaling, and other such properties to a Pattern reference.
+
+ Note that the methods for these classes try to avoid copying wherever possible, so unless
+  otherwise noted, assume that arguments are stored by-reference.
+
+
+ Dependencies:
+    - numpy
+    - matplotlib    [Pattern.visualize(...)]
+    - python-gdsii  [masque.file.gdsii]
+    - svgwrite      [masque.file.svg]
+"""
+
+from .error import PatternError
+from .shapes import Shape
+from .label import Label
+from .subpattern import SubPattern
+from .pattern import Pattern
+
+
+__author__ = 'Jan Petykiewicz'
+
+version = '0.4'
diff --git a/masque/error.py b/masque/error.py
new file mode 100644
index 0000000..8a67b6e
--- /dev/null
+++ b/masque/error.py
@@ -0,0 +1,9 @@
+class PatternError(Exception):
+    """
+    Simple Exception for Pattern objects and their contents
+    """
+    def __init__(self, value):
+        self.value = value
+
+    def __str__(self):
+        return repr(self.value)
diff --git a/masque/file/__init__.py b/masque/file/__init__.py
new file mode 100644
index 0000000..8de11a7
--- /dev/null
+++ b/masque/file/__init__.py
@@ -0,0 +1,3 @@
+"""
+Functions for reading from and writing to various file formats.
+"""
\ No newline at end of file
diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
new file mode 100644
index 0000000..0bc9656
--- /dev/null
+++ b/masque/file/gdsii.py
@@ -0,0 +1,380 @@
+"""
+GDSII file format readers and writers
+"""
+# python-gdsii
+import gdsii.library
+import gdsii.structure
+import gdsii.elements
+
+from typing import List, Any, Dict
+import re
+import numpy
+
+from .utils import mangle_name, make_dose_table
+from .. import Pattern, SubPattern, PatternError, Label
+from ..shapes import Polygon
+from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+def write(patterns: Pattern or List[Pattern],
+          filename: str,
+          meters_per_unit: float,
+          logical_units_per_unit: float = 1):
+    """
+    Write a Pattern or list of patterns to a GDSII file, by first calling
+     .polygonize() to change the shapes into polygons, and then writing patterns
+     as GDSII structures, polygons as boundary elements, and subpatterns as structure
+     references (sref).
+
+     For each shape,
+        layer is chosen to be equal to shape.layer if it is an int,
+            or shape.layer[0] if it is a tuple
+        datatype is chosen to be shape.layer[1] if available,
+            otherwise 0
+
+    Note that this function modifies the Pattern.
+
+    It is often a good idea to run pattern.subpatternize() prior to calling this function,
+     especially if calling .polygonize() will result in very many vertices.
+
+    If you want pattern polygonized with non-default arguments, just call pattern.polygonize()
+     prior to calling this function.
+
+    :param patterns: A Pattern or list of patterns to write to file. Modified by this function.
+    :param filename: Filename to write to.
+    :param meters_per_unit: Written into the GDSII file, meters per (database) length unit.
+        All distances are assumed to be an integer multiple of this unit, and are stored as such.
+    :param logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a
+        "logical" unit which is different from the "database" unit, for display purposes.
+        Default 1.
+    """
+    # Create library
+    lib = gdsii.library.Library(version=600,
+                                name='masque-gdsii-write'.encode('ASCII'),
+                                logical_unit=logical_units_per_unit,
+                                physical_unit=meters_per_unit)
+
+    if isinstance(patterns, Pattern):
+        patterns = [patterns]
+
+    # Get a dict of id(pattern) -> pattern
+    patterns_by_id = {id(pattern): pattern for pattern in patterns}
+    for pattern in patterns:
+        patterns_by_id.update(pattern.referenced_patterns_by_id())
+
+    # Now create a structure for each pattern, and add in any Boundary and SREF elements
+    for pat in patterns_by_id.values():
+        sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', pat.name)
+        encoded_name = sanitized_name.encode('ASCII')
+        if len(encoded_name) == 0:
+            raise PatternError('Zero-length name after sanitize+encode, originally "{}"'.format(pat.name))
+        structure = gdsii.structure.Structure(name=encoded_name)
+        lib.append(structure)
+
+
+        # Add a Boundary element for each shape
+        for shape in pat.shapes:
+            layer, data_type = _mlayer2gds(shape.layer)
+            for polygon in shape.to_polygons():
+                xy_open = numpy.round(polygon.vertices + polygon.offset).astype(int)
+                xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
+                structure.append(gdsii.elements.Boundary(layer=layer,
+                                                         data_type=data_type,
+                                                         xy=xy_closed))
+        for label in pat.labels:
+            layer, text_type = _mlayer2gds(label.layer)
+            xy = numpy.round([label.offset]).astype(int)
+            structure.append(gdsii.elements.Text(layer=layer,
+                                                 text_type=text_type,
+                                                 xy=xy,
+                                                 string=label.string.encode('ASCII')))
+
+        # Add an SREF for each subpattern entry
+        #  strans must be set for angle and mag to take effect
+        for subpat in pat.subpatterns:
+            sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', subpat.pattern.name)
+            encoded_name = sanitized_name.encode('ASCII')
+            if len(encoded_name) == 0:
+                raise PatternError('Zero-length name after sanitize+encode, originally "{}"'.format(subpat.pattern.name))
+            sref = gdsii.elements.SRef(struct_name=encoded_name,
+                                       xy=numpy.round([subpat.offset]).astype(int))
+            sref.strans = 0
+            sref.angle = subpat.rotation * 180 / numpy.pi
+            mirror_x, mirror_y = subpat.mirrored
+            if mirror_y and mirror_y:
+                sref.angle += 180
+            elif mirror_x:
+                sref.strans = set_bit(sref.strans, 15 - 0, True)
+            elif mirror_y:
+                sref.angle += 180
+                sref.strans = set_bit(sref.strans, 15 - 0, True)
+            sref.mag = subpat.scale
+            structure.append(sref)
+
+    with open(filename, mode='wb') as stream:
+        lib.save(stream)
+
+
+def write_dose2dtype(patterns: Pattern or List[Pattern],
+                     filename: str,
+                     meters_per_unit: float,
+                     logical_units_per_unit: float = 1
+                     ) -> List[float]:
+    """
+    Write a Pattern or list of patterns to a GDSII file, by first calling
+     .polygonize() to change the shapes into polygons, and then writing patterns
+     as GDSII structures, polygons as boundary elements, and subpatterns as structure
+     references (sref).
+
+     For each shape,
+        layer is chosen to be equal to shape.layer if it is an int,
+            or shape.layer[0] if it is a tuple
+        datatype is chosen arbitrarily, based on calcualted dose for each shape.
+            Shapes with equal calcualted dose will have the same datatype.
+            A list of doses is retured, providing a mapping between datatype
+            (list index) and dose (list entry).
+
+    Note that this function modifies the Pattern(s).
+
+    It is often a good idea to run pattern.subpatternize() prior to calling this function,
+     especially if calling .polygonize() will result in very many vertices.
+
+    If you want pattern polygonized with non-default arguments, just call pattern.polygonize()
+     prior to calling this function.
+
+    :param patterns: A Pattern or list of patterns to write to file. Modified by this function.
+    :param filename: Filename to write to.
+    :param meters_per_unit: Written into the GDSII file, meters per (database) length unit.
+        All distances are assumed to be an integer multiple of this unit, and are stored as such.
+    :param logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a
+        "logical" unit which is different from the "database" unit, for display purposes.
+        Default 1.
+    :returns: A list of doses, providing a mapping between datatype (int, list index)
+                and dose (float, list entry).
+    """
+    # Create library
+    lib = gdsii.library.Library(version=600,
+                                name='masque-write_dose2dtype'.encode('ASCII'),
+                                logical_unit=logical_units_per_unit,
+                                physical_unit=meters_per_unit)
+
+    if isinstance(patterns, Pattern):
+        patterns = [patterns]
+
+    # Get a dict of id(pattern) -> pattern
+    patterns_by_id = {id(pattern): pattern for pattern in patterns}
+    for pattern in patterns:
+        patterns_by_id.update(pattern.referenced_patterns_by_id())
+
+    # Get a table of (id(pat), written_dose) for each pattern and subpattern
+    sd_table = make_dose_table(patterns)
+
+    # Figure out all the unique doses necessary to write this pattern
+    #  This means going through each row in sd_table and adding the dose values needed to write
+    #  that subpattern at that dose level
+    dose_vals = set()
+    for pat_id, pat_dose in sd_table:
+        pat = patterns_by_id[pat_id]
+        [dose_vals.add(shape.dose * pat_dose) for shape in pat.shapes]
+
+    if len(dose_vals) > 256:
+        raise PatternError('Too many dose values: {}, maximum 256 when using dtypes.'.format(len(dose_vals)))
+
+    dose_vals_list = list(dose_vals)
+
+    # Now create a structure for each row in sd_table (ie, each pattern + dose combination)
+    #  and add in any Boundary and SREF elements
+    for pat_id, pat_dose in sd_table:
+        pat = patterns_by_id[pat_id]
+
+        encoded_name = mangle_name(pat, pat_dose).encode('ASCII')
+        if len(encoded_name) == 0:
+            raise PatternError('Zero-length name after mangle+encode, originally "{}"'.format(pat.name))
+        structure = gdsii.structure.Structure(name=encoded_name)
+        lib.append(structure)
+
+        # Add a Boundary element for each shape
+        for shape in pat.shapes:
+            for polygon in shape.to_polygons():
+                data_type = dose_vals_list.index(polygon.dose * pat_dose)
+                xy_open = numpy.round(polygon.vertices + polygon.offset).astype(int)
+                xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
+                if is_scalar(polygon.layer):
+                    layer = polygon.layer
+                else:
+                    layer = polygon.layer[0]
+                structure.append(gdsii.elements.Boundary(layer=layer,
+                                                         data_type=data_type,
+                                                         xy=xy_closed))
+        for label in pat.labels:
+            layer, text_type = _mlayer2gds(label.layer)
+            xy = numpy.round([label.offset]).astype(int)
+            structure.append(gdsii.elements.Text(layer=layer,
+                                                 text_type=text_type,
+                                                 xy=xy,
+                                                 string=label.string.encode('ASCII')))
+
+        # Add an SREF for each subpattern entry
+        #  strans must be set for angle and mag to take effect
+        for subpat in pat.subpatterns:
+            dose_mult = subpat.dose * pat_dose
+            encoded_name = mangle_name(subpat.pattern, dose_mult).encode('ASCII')
+            if len(encoded_name) == 0:
+                raise PatternError('Zero-length name after mangle+encode, originally "{}"'.format(subpat.pattern.name))
+            sref = gdsii.elements.SRef(struct_name=encoded_name,
+                                       xy=numpy.round([subpat.offset]).astype(int))
+            sref.strans = 0
+            sref.angle = subpat.rotation * 180 / numpy.pi
+            sref.mag = subpat.scale
+            mirror_x, mirror_y = subpat.mirrored
+            if mirror_y and mirror_y:
+                sref.angle += 180
+            elif mirror_x:
+                sref.strans = set_bit(sref.strans, 15 - 0, True)
+            elif mirror_y:
+                sref.angle += 180
+                sref.strans = set_bit(sref.strans, 15 - 0, True)
+            structure.append(sref)
+
+    with open(filename, mode='wb') as stream:
+        lib.save(stream)
+
+    return dose_vals_list
+
+
+def read_dtype2dose(filename: str) -> (List[Pattern], Dict[str, Any]):
+    """
+    Alias for read(filename, use_dtype_as_dose=True)
+    """
+    return read(filename, use_dtype_as_dose=True)
+
+
+def read(filename: str,
+         use_dtype_as_dose: bool = False,
+         clean_vertices: bool = True,
+         ) -> (Dict[str, Pattern], Dict[str, Any]):
+    """
+    Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
+     translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
+     are translated into SubPattern objects.
+
+    Additional library info is returned in a dict, containing:
+      'name': name of the library
+      'meters_per_unit': number of meters per database unit (all values are in database units)
+      'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
+                                per database unit
+
+    :param filename: Filename specifying a GDSII file to read from.
+    :param use_dtype_as_dose: If false, set each polygon's layer to (gds_layer, gds_datatype).
+            If true, set the layer to gds_layer and the dose to gds_datatype.
+            Default False.
+    :param clean_vertices: If true, remove any redundant vertices when loading polygons.
+            The cleaning process removes any polygons with zero area or <3 vertices.
+            Default True.
+    :return: Tuple: (Dict of pattern_name:Patterns generated from GDSII structures, Dict of GDSII library info)
+    """
+
+    with open(filename, mode='rb') as stream:
+        lib = gdsii.library.Library.load(stream)
+
+    library_info = {'name': lib.name.decode('ASCII'),
+                    'meters_per_unit': lib.physical_unit,
+                    'logical_units_per_unit': lib.logical_unit,
+                    }
+
+    def ref_element_to_subpat(element, offset: vector2) -> SubPattern:
+        # Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None
+        #  and sets the instance attribute .ref_name to the struct_name.
+        #
+        # BUG: "Absolute" means not affected by parent elements.
+        #       That's not currently supported by masque at all, so need to either tag it and
+        #       undo the parent transformations, or implement it in masque.
+        subpat = SubPattern(pattern=None, offset=offset)
+        subpat.ref_name = element.struct_name
+        if element.strans is not None:
+            if element.mag is not None:
+                subpat.scale = element.mag
+                # Bit 13 means absolute scale
+                if get_bit(element.strans, 15 - 13):
+                    #subpat.offset *= subpat.scale
+                    raise PatternError('Absolute scale is not implemented yet!')
+            if element.angle is not None:
+                subpat.rotation = element.angle * numpy.pi / 180
+                # Bit 14 means absolute rotation
+                if get_bit(element.strans, 15 - 14):
+                    #subpat.offset = numpy.dot(rotation_matrix_2d(subpat.rotation), subpat.offset)
+                    raise PatternError('Absolute rotation is not implemented yet!')
+            # Bit 0 means mirror x-axis
+            if get_bit(element.strans, 15 - 0):
+                subpat.mirror(axis=0)
+        return subpat
+
+
+    patterns = []
+    for structure in lib:
+        pat = Pattern(name=structure.name.decode('ASCII'))
+        for element in structure:
+            # Switch based on element type:
+            if isinstance(element, gdsii.elements.Boundary):
+                if use_dtype_as_dose:
+                    shape = Polygon(vertices=element.xy[:-1],
+                                    dose=element.data_type,
+                                    layer=element.layer)
+                else:
+                    shape = Polygon(vertices=element.xy[:-1],
+                                    layer=(element.layer, element.data_type))
+                if clean_vertices:
+                    try:
+                        shape.clean_vertices()
+                    except PatternError:
+                        continue
+
+                pat.shapes.append(shape)
+
+            elif isinstance(element, gdsii.elements.Text):
+                label = Label(offset=element.xy,
+                              layer=(element.layer, element.text_type),
+                              string=element.string.decode('ASCII'))
+                pat.labels.append(label)
+
+            elif isinstance(element, gdsii.elements.SRef):
+                pat.subpatterns.append(ref_element_to_subpat(element, element.xy))
+
+            elif isinstance(element, gdsii.elements.ARef):
+                xy = numpy.array(element.xy)
+                origin = xy[0]
+                col_spacing = (xy[1] - origin) / element.cols
+                row_spacing = (xy[2] - origin) / element.rows
+
+                for c in range(element.cols):
+                    for r in range(element.rows):
+                        offset = origin + c * col_spacing + r * row_spacing
+                        pat.subpatterns.append(ref_element_to_subpat(element, offset))
+
+        patterns.append(pat)
+
+    # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries
+    #  according to the subpattern.ref_name (which is deleted after use).
+    patterns_dict = dict(((p.name, p) for p in patterns))
+    for p in patterns_dict.values():
+        for sp in p.subpatterns:
+            sp.pattern = patterns_dict[sp.ref_name.decode('ASCII')]
+            del sp.ref_name
+
+    return patterns_dict, library_info
+
+
+def _mlayer2gds(mlayer):
+    if is_scalar(mlayer):
+        layer = mlayer
+        data_type = 0
+    else:
+        layer = mlayer[0]
+        if len(mlayer) > 1:
+            data_type = mlayer[1]
+        else:
+            data_type = 0
+    return layer, data_type
diff --git a/masque/file/svg.py b/masque/file/svg.py
new file mode 100644
index 0000000..6a28c25
--- /dev/null
+++ b/masque/file/svg.py
@@ -0,0 +1,139 @@
+"""
+SVG file format readers and writers
+"""
+
+import svgwrite
+import numpy
+
+from .utils import mangle_name
+from .. import Pattern
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+def write(pattern: Pattern,
+          filename: str,
+          custom_attributes: bool=False):
+    """
+    Write a Pattern to an SVG file, by first calling .polygonize() on it
+     to change the shapes into polygons, and then writing patterns as SVG
+     groups (<g>, inside <defs>), polygons as paths (<path>), and subpatterns
+     as <use> elements.
+
+    Note that this function modifies the Pattern.
+
+    If custom_attributes is True, non-standard pattern_layer and pattern_dose attributes
+     are written to the relevant elements.
+
+    It is often a good idea to run pattern.subpatternize() on pattern prior to
+     calling this function, especially if calling .polygonize() will result in very
+     many vertices.
+
+    If you want pattern polygonized with non-default arguments, just call pattern.polygonize()
+     prior to calling this function.
+
+    :param pattern: Pattern to write to file. Modified by this function.
+    :param filename: Filename to write to.
+    :param custom_attributes: Whether to write non-standard pattern_layer and
+            pattern_dose attributes to the SVG elements.
+    """
+
+    # Polygonize pattern
+    pattern.polygonize()
+
+    [bounds_min, bounds_max] = pattern.get_bounds()
+
+    viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2))
+    viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox)
+
+    # Create file
+    svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
+                           debug=(not custom_attributes))
+
+    # Get a dict of id(pattern) -> pattern
+    patterns_by_id = {**(pattern.referenced_patterns_by_id()), id(pattern): pattern}
+
+    # Now create a group for each row in sd_table (ie, each pattern + dose combination)
+    #  and add in any Boundary and Use elements
+    for pat in patterns_by_id.values():
+        svg_group = svg.g(id=mangle_name(pat), fill='blue', stroke='red')
+
+        for shape in pat.shapes:
+            for polygon in shape.to_polygons():
+                path_spec = poly2path(polygon.vertices + polygon.offset)
+
+                path = svg.path(d=path_spec)
+                if custom_attributes:
+                    path['pattern_layer'] = polygon.layer
+                    path['pattern_dose'] = polygon.dose
+
+                svg_group.add(path)
+
+        for subpat in pat.subpatterns:
+            transform = 'scale({:g}) rotate({:g}) translate({:g},{:g})'.format(
+                subpat.scale, subpat.rotation, subpat.offset[0], subpat.offset[1])
+            use = svg.use(href='#' + mangle_name(subpat.pattern), transform=transform)
+            if custom_attributes:
+                use['pattern_dose'] = subpat.dose
+            svg_group.add(use)
+
+        svg.defs.add(svg_group)
+    svg.add(svg.use(href='#' + mangle_name(pattern)))
+    svg.save()
+
+
+def write_inverted(pattern: Pattern, filename: str):
+    """
+    Write an inverted Pattern to an SVG file, by first calling .polygonize() and
+     .flatten() on it to change the shapes into polygons, then drawing a bounding
+     box and drawing the polygons with reverse vertex order inside it, all within
+     one <path> element.
+
+    Note that this function modifies the Pattern.
+
+    If you want pattern polygonized with non-default arguments, just call pattern.polygonize()
+     prior to calling this function.
+
+    :param pattern: Pattern to write to file. Modified by this function.
+    :param filename: Filename to write to.
+    """
+    # Polygonize and flatten pattern
+    pattern.polygonize().flatten()
+
+    [bounds_min, bounds_max] = pattern.get_bounds()
+
+    viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2))
+    viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox)
+
+    # Create file
+    svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string)
+
+    # Draw bounding box
+    slab_edge = [[bounds_min[0] - 1, bounds_max[1] + 1],
+                 [bounds_max[0] + 1, bounds_max[1] + 1],
+                 [bounds_max[0] + 1, bounds_min[1] - 1],
+                 [bounds_min[0] - 1, bounds_min[1] - 1]]
+    path_spec = poly2path(slab_edge)
+
+    # Draw polygons with reversed vertex order
+    for shape in pattern.shapes:
+        for polygon in shape.to_polygons():
+            path_spec += poly2path(polygon.vertices[::-1] + polygon.offset)
+
+    svg.add(svg.path(d=path_spec, fill='blue', stroke='red'))
+    svg.save()
+
+
+def poly2path(vertices: numpy.ndarray) -> str:
+    """
+    Create an SVG path string from an Nx2 list of vertices.
+
+    :param vertices: Nx2 array of vertices.
+    :return: SVG path-string.
+    """
+    commands = 'M{:g},{:g} '.format(vertices[0][0], vertices[0][1])
+    for vertex in vertices[1:]:
+        commands += 'L{:g},{:g}'.format(vertex[0], vertex[1])
+    commands += ' Z   '
+    return commands
diff --git a/masque/file/utils.py b/masque/file/utils.py
new file mode 100644
index 0000000..97e3d36
--- /dev/null
+++ b/masque/file/utils.py
@@ -0,0 +1,42 @@
+"""
+Helper functions for file reading and writing
+"""
+import re
+from typing import Set, Tuple, List
+
+from masque.pattern import Pattern
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+def mangle_name(pattern: Pattern, dose_multiplier: float=1.0) -> str:
+    """
+    Create a name using pattern.name, id(pattern), and the dose multiplier.
+
+    :param pattern: Pattern whose name we want to mangle.
+    :param dose_multiplier: Dose multiplier to mangle with.
+    :return: Mangled name.
+    """
+    expression = re.compile('[^A-Za-z0-9_\?\$]')
+    full_name = '{}_{}_{}'.format(pattern.name, dose_multiplier, id(pattern))
+    sanitized_name = expression.sub('_', full_name)
+    return sanitized_name
+
+
+def make_dose_table(patterns: List[Pattern], dose_multiplier: float=1.0) -> Set[Tuple[int, float]]:
+    """
+    Create a set containing (id(pat), written_dose) for each pattern (including subpatterns)
+
+    :param pattern: Source Patterns.
+    :param dose_multiplier: Multiplier for all written_dose entries.
+    :return: {(id(subpat.pattern), written_dose), ...}
+    """
+    dose_table = {(id(pattern), dose_multiplier) for pattern in patterns}
+    for pattern in patterns:
+        for subpat in pattern.subpatterns:
+            subpat_dose_entry = (id(subpat.pattern), subpat.dose * dose_multiplier)
+            if subpat_dose_entry not in dose_table:
+                subpat_dose_table = make_dose_table([subpat.pattern], subpat.dose * dose_multiplier)
+                dose_table = dose_table.union(subpat_dose_table)
+    return dose_table
diff --git a/masque/label.py b/masque/label.py
new file mode 100644
index 0000000..b3bbb6f
--- /dev/null
+++ b/masque/label.py
@@ -0,0 +1,129 @@
+from typing import List, Tuple
+import copy
+import numpy
+from numpy import pi
+
+from . import PatternError
+from .utils import is_scalar, vector2, rotation_matrix_2d
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+class Label:
+    """
+    A circle, which has a position and radius.
+    """
+
+    # [x_offset, y_offset]
+    _offset = numpy.array([0.0, 0.0])   # type: numpy.ndarray
+
+    # Layer (integer >= 0)
+    _layer = 0                          # type: int or Tuple
+
+    # Label string
+    _string = None                      # type: str
+
+    # ---- Properties
+    # offset property
+    @property
+    def offset(self) -> numpy.ndarray:
+        """
+        [x, y] offset
+
+        :return: [x_offset, y_offset]
+        """
+        return self._offset
+
+    @offset.setter
+    def offset(self, val: vector2):
+        if not isinstance(val, numpy.ndarray):
+            val = numpy.array(val, dtype=float)
+
+        if val.size != 2:
+            raise PatternError('Offset must be convertible to size-2 ndarray')
+        self._offset = val.flatten()
+
+    # layer property
+    @property
+    def layer(self) -> int or Tuple[int]:
+        """
+        Layer number (int or tuple of ints)
+
+        :return: Layer
+        """
+        return self._layer
+
+    @layer.setter
+    def layer(self, val: int or List[int]):
+        self._layer = val
+
+    # string property
+    @property
+    def string(self) -> str:
+        """
+        Label string (str)
+
+        :return: string
+        """
+        return self._string
+
+    @string.setter
+    def string(self, val: str):
+        self._string = val
+
+    def __init__(self,
+                 string: str,
+                 offset: vector2=(0.0, 0.0),
+                 layer: int=0):
+        self.string = string
+        self.offset = numpy.array(offset, dtype=float)
+        self.layer = layer
+
+
+    # ---- Non-abstract methods
+    def copy(self) -> 'Label':
+        """
+        Returns a deep copy of the shape.
+
+        :return: Deep copy of self
+        """
+        return copy.deepcopy(self)
+
+    def translate(self, offset: vector2) -> 'Label':
+        """
+        Translate the shape by the given offset
+
+        :param offset: [x_offset, y,offset]
+        :return: self
+        """
+        self.offset += offset
+        return self
+
+    def rotate_around(self, pivot: vector2, rotation: float) -> 'Label':
+        """
+        Rotate the shape around a point.
+
+        :param pivot: Point (x, y) to rotate around
+        :param rotation: Angle to rotate by (counterclockwise, radians)
+        :return: self
+        """
+        pivot = numpy.array(pivot, dtype=float)
+        self.translate(-pivot)
+        self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
+        self.translate(+pivot)
+        return self
+
+    def get_bounds(self) -> numpy.ndarray:
+        """
+        Return the bounds of the label.
+
+        Labels are assumed to take up 0 area, i.e.
+        bounds = [self.offset,
+                  self.offset]
+
+        :return: Bounds [[xmin, xmax], [ymin, ymax]]
+        """
+        return numpy.array([self.offset, self.offset])
+
+
diff --git a/masque/pattern.py b/masque/pattern.py
new file mode 100644
index 0000000..4c2807a
--- /dev/null
+++ b/masque/pattern.py
@@ -0,0 +1,538 @@
+"""
+ Base object for containing a lithography mask.
+"""
+
+from typing import List, Callable, Tuple, Dict, Union
+import copy
+import itertools
+import pickle
+from collections import defaultdict
+
+import numpy
+# .visualize imports matplotlib and matplotlib.collections
+
+from .subpattern import SubPattern
+from .shapes import Shape, Polygon
+from .label import Label
+from .utils import rotation_matrix_2d, vector2
+from .error import PatternError
+
+__author__ = 'Jan Petykiewicz'
+
+
+class Pattern:
+    """
+    2D layout consisting of some set of shapes and references to other Pattern objects
+     (via SubPattern). Shapes are assumed to inherit from .shapes.Shape or provide equivalent
+     functions.
+
+    :var shapes: List of all shapes in this Pattern. Elements in this list are assumed to inherit
+            from Shape or provide equivalent functions.
+    :var subpatterns: List of all SubPattern objects in this Pattern. Multiple SubPattern objects
+            may reference the same Pattern object.
+    :var name: An identifier for this object. Not necessarily unique.
+    """
+    shapes = None               # type: List[Shape]
+    labels = None               # type: List[Labels]
+    subpatterns = None          # type: List[SubPattern]
+    name = None                 # type: str
+
+    def __init__(self,
+                 shapes: List[Shape]=(),
+                 labels: List[Label]=(),
+                 subpatterns: List[SubPattern]=(),
+                 name: str='',
+                 ):
+        """
+        Basic init; arguments get assigned to member variables.
+         Non-list inputs for shapes and subpatterns get converted to lists.
+
+        :param shapes: Initial shapes in the Pattern
+        :param labels: Initial labels in the Pattern
+        :param subpatterns: Initial subpatterns in the Pattern
+        :param name: An identifier for the Pattern
+        """
+        if isinstance(shapes, list):
+            self.shapes = shapes
+        else:
+            self.shapes = list(shapes)
+
+        if isinstance(labels, list):
+            self.labels = labels
+        else:
+            self.labels = list(labels)
+
+        if isinstance(subpatterns, list):
+            self.subpatterns = subpatterns
+        else:
+            self.subpatterns = list(subpatterns)
+
+        self.name = name
+
+    def append(self, other_pattern: 'Pattern') -> 'Pattern':
+        """
+        Appends all shapes, labels and subpatterns from other_pattern to self's shapes,
+          labels, and supbatterns.
+
+        :param other_pattern: The Pattern to append
+        :return: self
+        """
+        self.subpatterns += other_pattern.subpatterns
+        self.shapes += other_pattern.shapes
+        self.labels += other_pattern.labels
+        return self
+
+    def subset(self,
+               shapes_func: Callable[[Shape], bool]=None,
+               labels_func: Callable[[Label], bool]=None,
+               subpatterns_func: Callable[[SubPattern], bool]=None,
+               recursive: bool=False,
+               ) -> 'Pattern':
+        """
+        Returns a Pattern containing only the entities (e.g. shapes) for which the
+          given entity_func returns True.
+        Self is _not_ altered, but shapes, labels, and subpatterns are _not_ copied.
+
+        :param shapes_func: Given a shape, returns a boolean denoting whether the shape is a member
+             of the subset. Default always returns False.
+        :param labels_func: Given a label, returns a boolean denoting whether the label is a member
+             of the subset. Default always returns False.
+        :param subpatterns_func: Given a subpattern, returns a boolean denoting if it is a member
+             of the subset. Default always returns False.
+        :param recursive: If True, also calls .subset() recursively on patterns referenced by this
+             pattern.
+        :return: A Pattern containing all the shapes and subpatterns for which the parameter
+             functions return True
+        """
+        def do_subset(src):
+            pat = Pattern(name=src.name)
+            if shapes_func is not None:
+                pat.shapes = [s for s in src.shapes if shapes_func(s)]
+            if labels_func is not None:
+                pat.labels = [s for s in src.labels if labels_func(s)]
+            if subpatterns_func is not None:
+                pat.subpatterns = [s for s in src.subpatterns if subpatterns_func(s)]
+            return pat
+
+        if recursive:
+            pat = self.apply(do_subset)
+        else:
+            pat = do_subset(self)
+        return pat
+
+    def apply(self,
+              func: Callable[['Pattern'], 'Pattern'],
+              memo: Dict[int, 'Pattern']=None,
+              ) -> 'Pattern':
+        """
+        Recursively apply func() to this pattern and any pattern it references.
+        func() is expected to take and return a Pattern.
+        func() is first applied to the pattern as a whole, then any referenced patterns.
+        It is only applied to any given pattern once, regardless of how many times it is
+            referenced.
+
+        :param func: Function which accepts a Pattern, and returns a pattern.
+        :param memo: Dictionary used to avoid re-running on multiply-referenced patterns.
+            Stores {id(pattern): func(pattern)} for patterns which have already been processed.
+            Default None (no already-processed patterns).
+        :return: The result of applying func() to this pattern and all subpatterns.
+        :raises: PatternError if called on a pattern containing a circular reference.
+        """
+        if memo is None:
+            memo = {}
+
+        pat_id = id(self)
+        if pat_id not in memo:
+            memo[pat_id] = None
+            pat = func(self)
+            for subpat in pat.subpatterns:
+                subpat.pattern = subpat.pattern.apply(func, memo)
+            memo[pat_id] = pat
+        elif memo[pat_id] is None:
+            raise PatternError('.apply() called on pattern with circular reference')
+        else:
+            pat = memo[pat_id]
+        return pat
+
+    def polygonize(self,
+                   poly_num_points: int=None,
+                   poly_max_arclen: float=None
+                   ) -> 'Pattern':
+        """
+        Calls .to_polygons(...) on all the shapes in this Pattern and any referenced patterns,
+         replacing them with the returned polygons.
+        Arguments are passed directly to shape.to_polygons(...).
+
+        :param poly_num_points: Number of points to use for each polygon. Can be overridden by
+             poly_max_arclen if that results in more points. Optional, defaults to shapes'
+             internal defaults.
+        :param poly_max_arclen: Maximum arclength which can be approximated by a single line
+             segment. Optional, defaults to shapes' internal defaults.
+        :return: self
+        """
+        old_shapes = self.shapes
+        self.shapes = list(itertools.chain.from_iterable(
+                        (shape.to_polygons(poly_num_points, poly_max_arclen)
+                         for shape in old_shapes)))
+        for subpat in self.subpatterns:
+            subpat.pattern.polygonize(poly_num_points, poly_max_arclen)
+        return self
+
+    def manhattanize(self,
+                     grid_x: numpy.ndarray,
+                     grid_y: numpy.ndarray
+                     ) -> 'Pattern':
+        """
+        Calls .polygonize() and .flatten on the pattern, then calls .manhattanize() on all the
+         resulting shapes, replacing them with the returned Manhattan polygons.
+
+        :param grid_x: List of allowed x-coordinates for the Manhattanized polygon edges.
+        :param grid_y: List of allowed y-coordinates for the Manhattanized polygon edges.
+        :return: self
+        """
+
+        self.polygonize().flatten()
+        old_shapes = self.shapes
+        self.shapes = list(itertools.chain.from_iterable(
+                        (shape.manhattanize(grid_x, grid_y) for shape in old_shapes)))
+        return self
+
+    def subpatternize(self,
+                      recursive: bool=True,
+                      norm_value: int=1e6,
+                      exclude_types: Tuple[Shape]=(Polygon,)
+                      ) -> 'Pattern':
+        """
+        Iterates through this Pattern and all referenced Patterns. Within each Pattern, it iterates
+         over all shapes, calling .normalized_form(norm_value) on them to retrieve a scale-,
+         offset-, dose-, and rotation-independent form. Each shape whose normalized form appears
+         more than once is removed and re-added using subpattern objects referencing a newly-created
+         Pattern containing only the normalized form of the shape.
+
+        Note that the default norm_value was chosen to give a reasonable precision when converting
+         to GDSII, which uses integer values for pixel coordinates.
+
+        :param recursive: Whether to call recursively on self's subpatterns. Default True.
+        :param norm_value: Passed to shape.normalized_form(norm_value). Default 1e6 (see function
+                note about GDSII)
+        :param exclude_types: Shape types passed in this argument are always left untouched, for
+                speed or convenience. Default: (Shapes.Polygon,)
+        :return: self
+        """
+
+        if exclude_types is None:
+            exclude_types = ()
+
+        if recursive:
+            for subpat in self.subpatterns:
+                subpat.pattern.subpatternize(recursive=True,
+                                             norm_value=norm_value,
+                                             exclude_types=exclude_types)
+
+        # Create a dict which uses the label tuple from .normalized_form() as a key, and which
+        #  stores (function_to_create_normalized_shape, [(index_in_shapes, values), ...]), where
+        #  values are the (offset, scale, rotation, dose) values as calculated by .normalized_form()
+        shape_table = defaultdict(lambda: [None, list()])
+        for i, shape in enumerate(self.shapes):
+            if not any((isinstance(shape, t) for t in exclude_types)):
+                label, values, func = shape.normalized_form(norm_value)
+                shape_table[label][0] = func
+                shape_table[label][1].append((i, values))
+
+        # Iterate over the normalized shapes in the table. If any normalized shape occurs more than
+        #  once, create a Pattern holding a normalized shape object, and add self.subpatterns
+        #  entries for each occurrence in self. Also, note down that we should delete the
+        #  self.shapes entries for which we made SubPatterns.
+        shapes_to_remove = []
+        for label in shape_table:
+            if len(shape_table[label][1]) > 1:
+                shape = shape_table[label][0]()
+                pat = Pattern(shapes=[shape])
+
+                for i, values in shape_table[label][1]:
+                    (offset, scale, rotation, dose) = values
+                    subpat = SubPattern(pattern=pat, offset=offset, scale=scale,
+                                        rotation=rotation, dose=dose)
+                    self.subpatterns.append(subpat)
+                    shapes_to_remove.append(i)
+
+        # Remove any shapes for which we have created subpatterns.
+        for i in sorted(shapes_to_remove, reverse=True):
+            del self.shapes[i]
+
+        return self
+
+    def as_polygons(self) -> List[numpy.ndarray]:
+        """
+        Represents the pattern as a list of polygons.
+
+        Deep-copies the pattern, then calls .polygonize() and .flatten() on the copy in order to
+         generate the list of polygons.
+
+        :return: A list of (Ni, 2) numpy.ndarrays specifying vertices of the polygons. Each ndarray
+            is of the form [[x0, y0], [x1, y1],...].
+        """
+        pat = copy.deepcopy(self).polygonize().flatten()
+        return [shape.vertices + shape.offset for shape in pat.shapes]
+
+    def referenced_patterns_by_id(self) -> Dict[int, 'Pattern']:
+        """
+        Create a dictionary of {id(pat): pat} for all Pattern objects referenced by this
+         Pattern (operates recursively on all referenced Patterns as well)
+
+        :return: Dictionary of {id(pat): pat} for all referenced Pattern objects
+        """
+        ids = {}
+        for subpat in self.subpatterns:
+            if id(subpat.pattern) not in ids:
+                ids[id(subpat.pattern)] = subpat.pattern
+                ids.update(subpat.pattern.referenced_patterns_by_id())
+        return ids
+
+    def get_bounds(self) -> Union[numpy.ndarray, None]:
+        """
+        Return a numpy.ndarray containing [[x_min, y_min], [x_max, y_max]], corresponding to the
+         extent of the Pattern's contents in each dimension.
+        Returns None if the Pattern is empty.
+
+        :return: [[x_min, y_min], [x_max, y_max]] or None
+        """
+        entries = self.shapes + self.subpatterns + self.labels
+        if not entries:
+            return None
+
+        init_bounds = entries[0].get_bounds()
+        min_bounds = init_bounds[0, :]
+        max_bounds = init_bounds[1, :]
+        for entry in entries[1:]:
+            bounds = entry.get_bounds()
+            min_bounds = numpy.minimum(min_bounds, bounds[0, :])
+            max_bounds = numpy.maximum(max_bounds, bounds[1, :])
+        return numpy.vstack((min_bounds, max_bounds))
+
+    def flatten(self) -> 'Pattern':
+        """
+        Removes all subpatterns and adds equivalent shapes.
+
+        :return: self
+        """
+        subpatterns = copy.deepcopy(self.subpatterns)
+        self.subpatterns = []
+        for subpat in subpatterns:
+            subpat.pattern.flatten()
+            p = subpat.as_pattern()
+            self.shapes += p.shapes
+            self.labels += p.labels
+        return self
+
+    def translate_elements(self, offset: vector2) -> 'Pattern':
+        """
+        Translates all shapes, label, and subpatterns by the given offset.
+
+        :param offset: Offset to translate by
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns + self.labels:
+            entry.translate(offset)
+        return self
+
+    def scale_elements(self, scale: float) -> 'Pattern':
+        """"
+        Scales all shapes and subpatterns by the given value.
+
+        :param scale: value to scale by
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns:
+            entry.scale(scale)
+        return self
+
+    def scale_by(self, c: float) -> 'Pattern':
+        """
+        Scale this Pattern by the given value
+         (all shapes and subpatterns and their offsets are scaled)
+
+        :param c: value to scale by
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns:
+            entry.offset *= c
+            entry.scale_by(c)
+        return self
+
+    def rotate_around(self, pivot: vector2, rotation: float) -> 'Pattern':
+        """
+        Rotate the Pattern around the a location.
+
+        :param pivot: Location to rotate around
+        :param rotation: Angle to rotate by (counter-clockwise, radians)
+        :return: self
+        """
+        pivot = numpy.array(pivot)
+        self.translate_elements(-pivot)
+        self.rotate_elements(rotation)
+        self.rotate_element_centers(rotation)
+        self.translate_elements(+pivot)
+        return self
+
+    def rotate_element_centers(self, rotation: float) -> 'Pattern':
+        """
+        Rotate the offsets of all shapes, labels, and subpatterns around (0, 0)
+
+        :param rotation: Angle to rotate by (counter-clockwise, radians)
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns + self.labels:
+            entry.offset = numpy.dot(rotation_matrix_2d(rotation), entry.offset)
+        return self
+
+    def rotate_elements(self, rotation: float) -> 'Pattern':
+        """
+        Rotate each shape and subpattern around its center (offset)
+
+        :param rotation: Angle to rotate by (counter-clockwise, radians)
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns:
+            entry.rotate(rotation)
+        return self
+
+    def mirror_element_centers(self, axis: int) -> 'Pattern':
+        """
+        Mirror the offsets of all shapes, labels, and subpatterns across an axis
+
+        :param axis: Axis to mirror across
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns + self.labels:
+            entry.offset[axis - 1] *= -1
+        return self
+
+    def mirror_elements(self, axis: int) -> 'Pattern':
+        """
+        Mirror each shape and subpattern across an axis, relative to its
+          center (offset)
+
+        :param axis: Axis to mirror across
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns:
+            entry.mirror(axis)
+        return self
+
+    def mirror(self, axis: int) -> 'Pattern':
+        """
+        Mirror the Pattern across an axis
+
+        :param axis: Axis to mirror across
+        :return: self
+        """
+        self.mirror_elements(axis)
+        self.mirror_element_centers(axis)
+        return self
+
+    def scale_element_doses(self, factor: float) -> 'Pattern':
+        """
+        Multiply all shape and subpattern doses by a factor
+
+        :param factor: Factor to multiply doses by
+        :return: self
+        """
+        for entry in self.shapes + self.subpatterns:
+            entry.dose *= factor
+        return self
+
+    def copy(self) -> 'Pattern':
+        """
+        Return a copy of the Pattern, deep-copying shapes and copying subpattern entries, but not
+         deep-copying any referenced patterns.
+
+	See also: Pattern.deepcopy()
+
+        :return: A copy of the current Pattern.
+        """
+        cp = copy.copy(self)
+        cp.shapes = copy.deepcopy(cp.shapes)
+        cp.labels = copy.deepcopy(cp.labels)
+        cp.subpatterns = [copy.copy(subpat) for subpat in cp.subpatterns]
+        return cp
+
+    def deepcopy(self) -> 'Pattern':
+        """
+        Convenience method for copy.deepcopy(pattern)
+
+        :return: A deep copy of the current Pattern.
+        """
+        return copy.deepcopy(self)
+
+    @staticmethod
+    def load(filename: str) -> 'Pattern':
+        """
+        Load a Pattern from a file
+
+        :param filename: Filename to load from
+        :return: Loaded Pattern
+        """
+        with open(filename, 'rb') as f:
+            tmp_dict = pickle.load(f)
+
+        pattern = Pattern()
+        pattern.__dict__.update(tmp_dict)
+        return pattern
+
+    def save(self, filename: str) -> 'Pattern':
+        """
+        Save the Pattern to a file
+
+        :param filename: Filename to save to
+        :return: self
+        """
+        with open(filename, 'wb') as f:
+            pickle.dump(self.__dict__, f, protocol=2)
+        return self
+
+    def visualize(self,
+                  offset: vector2=(0., 0.),
+                  line_color: str='k',
+                  fill_color: str='none',
+                  overdraw: bool=False):
+        """
+        Draw a picture of the Pattern and wait for the user to inspect it
+
+        Imports matplotlib.
+
+        :param offset: Coordinates to offset by before drawing
+        :param line_color: Outlines are drawn with this color (passed to matplotlib PolyCollection)
+        :param fill_color: Interiors are drawn with this color (passed to matplotlib PolyCollection)
+        :param overdraw: Whether to create a new figure or draw on a pre-existing one
+        """
+        # TODO: add text labels to visualize()
+        from matplotlib import pyplot
+        import matplotlib.collections
+
+        offset = numpy.array(offset, dtype=float)
+
+        if not overdraw:
+            figure = pyplot.figure()
+            pyplot.axis('equal')
+        else:
+            figure = pyplot.gcf()
+
+        axes = figure.gca()
+
+        polygons = []
+        for shape in self.shapes:
+            polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]
+
+        mpl_poly_collection = matplotlib.collections.PolyCollection(polygons,
+                                                                    facecolors=fill_color,
+                                                                    edgecolors=line_color)
+        axes.add_collection(mpl_poly_collection)
+        pyplot.axis('equal')
+
+        for subpat in self.subpatterns:
+            subpat.as_pattern().visualize(offset=offset, overdraw=True,
+                                          line_color=line_color, fill_color=fill_color)
+
+        if not overdraw:
+            pyplot.show()
diff --git a/masque/repetition.py b/masque/repetition.py
new file mode 100644
index 0000000..4b4ac5f
--- /dev/null
+++ b/masque/repetition.py
@@ -0,0 +1,236 @@
+"""
+    Repetitions provides support for efficiently nesting multiple identical
+     instances of a Pattern in the same parent Pattern.
+"""
+
+from typing import Union, List
+
+import numpy
+from numpy import pi
+
+from .error import PatternError
+from .utils import is_scalar, rotation_matrix_2d, vector2
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+# TODO need top-level comment about what order rotation/scale/offset/mirror/array are applied
+
+class GridRepetition:
+    """
+    SubPattern provides basic support for nesting Pattern objects within each other, by adding
+     offset, rotation, scaling, and associated methods.
+    """
+
+    pattern = None          # type: Pattern
+
+    _offset = (0.0, 0.0)    # type: numpy.ndarray
+    _rotation = 0.0         # type: float
+    _dose = 1.0             # type: float
+    _scale = 1.0            # type: float
+    _mirrored = None        # type: List[bool]
+
+    a_vector = None         # type: List[int]
+    b_vector = None         # type: List[int]
+    a_count = None          # type: int
+    b_count = 1             # type: int
+
+    def __init__(self,
+                 pattern: 'Pattern',
+                 a_vector: List[int],
+                 a_count: int,
+                 b_vector: List[int] = None,
+                 b_count: int = 1,
+                 offset: vector2 = (0.0, 0.0),
+                 rotation: float = 0.0,
+                 mirrored: List[bool] = None,
+                 dose: float = 1.0,
+                 scale: float = 1.0):
+        """
+        :param a_vector: First lattice vector, of the form [x, y].
+            Specifies center-to-center spacing between adjacent elements.
+        :param a_count: Number of elements in the a_vector direction.
+        :param b_vector: Second lattice vector, of the form [x, y].
+            Specifies center-to-center spacing between adjacent elements.
+            Can be omitted when specifying a 1D array.
+        :param b_count: Number of elements in the b_vector direction.
+            Should be omitted if b_vector was omitted.
+        :raises: InvalidDataError if b_* inputs conflict with each other
+            or a_count < 1.
+        """
+        if b_vector is None:
+            if b_count > 1:
+                raise PatternError('Repetition has b_count > 1 but no b_vector')
+            else:
+                b_vector = numpy.array([0.0, 0.0])
+
+        if a_count < 1:
+            raise InvalidDataError('Repetition has too-small a_count: '
+                                   '{}'.format(a_count))
+        if b_count < 1:
+            raise InvalidDataError('Repetition has too-small b_count: '
+                                   '{}'.format(b_count))
+        self.a_vector = a_vector
+        self.b_vector = b_vector
+        self.a_count = a_count
+        self.b_count = b_count
+
+        self.pattern = pattern
+        self.offset = offset
+        self.rotation = rotation
+        self.dose = dose
+        self.scale = scale
+        if mirrored is None:
+            mirrored = [False, False]
+        self.mirrored = mirrored
+
+    # offset property
+    @property
+    def offset(self) -> numpy.ndarray:
+        return self._offset
+
+    @offset.setter
+    def offset(self, val: vector2):
+        if not isinstance(val, numpy.ndarray):
+            val = numpy.array(val, dtype=float)
+
+        if val.size != 2:
+            raise PatternError('Offset must be convertible to size-2 ndarray')
+        self._offset = val.flatten()
+
+    # dose property
+    @property
+    def dose(self) -> float:
+        return self._dose
+
+    @dose.setter
+    def dose(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Dose must be a scalar')
+        if not val >= 0:
+            raise PatternError('Dose must be non-negative')
+        self._dose = val
+
+    # scale property
+    @property
+    def scale(self) -> float:
+        return self._scale
+
+    @scale.setter
+    def scale(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Scale must be a scalar')
+        if not val > 0:
+            raise PatternError('Scale must be positive')
+        self._scale = val
+
+    # Rotation property [ccw]
+    @property
+    def rotation(self) -> float:
+        return self._rotation
+
+    @rotation.setter
+    def rotation(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Rotation must be a scalar')
+        self._rotation = val % (2 * pi)
+
+    # Mirrored property
+    @property
+    def mirrored(self) -> List[bool]:
+        return self._mirrored
+
+    @mirrored.setter
+    def mirrored(self, val: List[bool]):
+        if is_scalar(val):
+            raise PatternError('Mirrored must be a 2-element list of booleans')
+        self._mirrored = val
+
+    def as_pattern(self) -> 'Pattern':
+        """
+        Returns a copy of self.pattern which has been scaled, rotated, etc. according to this
+         SubPattern's properties.
+        :return: Copy of self.pattern that has been altered to reflect the SubPattern's properties.
+        """
+        xy = numpy.array(element.xy)
+        origin = xy[0]
+        col_spacing = (xy[1] - origin) / element.cols
+        row_spacing = (xy[2] - origin) / element.rows
+
+        for c in range(self.a_count):
+            for r in range(self.b_count):
+                offset = origin + c * col_spacing + r * row_spacing
+                pat.subpatterns.append(ref_element_to_subpat(element, offset))
+
+        pattern = self.pattern.deepcopy()
+        pattern.scale_by(self.scale)
+        [pattern.mirror(ax) for ax, do in enumerate(self.mirrored) if do]
+        pattern.rotate_around((0.0, 0.0), self.rotation)
+        pattern.translate_elements(self.offset)
+        pattern.scale_element_doses(self.dose)
+        return pattern
+
+    def translate(self, offset: vector2) -> 'GridRepetition':
+        """
+        Translate by the given offset
+
+        :param offset: Translate by this offset
+        :return: self
+        """
+        self.offset += offset
+        return self
+
+    def rotate_around(self, pivot: vector2, rotation: float) -> 'GridRepetition':
+        """
+        Rotate around a point
+
+        :param pivot: Point to rotate around
+        :param rotation: Angle to rotate by (counterclockwise, radians)
+        :return: self
+        """
+        pivot = numpy.array(pivot, dtype=float)
+        self.translate(-pivot)
+        self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
+        self.rotate(rotation)
+        self.translate(+pivot)
+        return self
+
+    def rotate(self, rotation: float) -> 'GridRepetition':
+        """
+        Rotate around (0, 0)
+
+        :param rotation: Angle to rotate by (counterclockwise, radians)
+        :return: self
+        """
+        self.rotation += rotation
+        return self
+
+    def mirror(self, axis: int) -> 'GridRepetition':
+        """
+        Mirror the subpattern across an axis.
+
+        :param axis: Axis to mirror across.
+        :return: self
+        """
+        self.mirrored[axis] = not self.mirrored[axis]
+        return self
+
+    def get_bounds(self) -> numpy.ndarray or None:
+        """
+        Return a numpy.ndarray containing [[x_min, y_min], [x_max, y_max]], corresponding to the
+         extent of the SubPattern in each dimension.
+        Returns None if the contained Pattern is empty.
+
+        :return: [[x_min, y_min], [x_max, y_max]] or None
+        """
+        return self.as_pattern().get_bounds()
+
+    def scale_by(self, c: float) -> 'GridRepetition':
+        """
+        Scale the subpattern by a factor
+
+        :param c: scaling factor
+        """
+        self.scale *= c
+        return self
diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py
new file mode 100644
index 0000000..4c64204
--- /dev/null
+++ b/masque/shapes/__init__.py
@@ -0,0 +1,12 @@
+"""
+Shapes for use with the Pattern class, as well as the Shape abstract class from
+ which they are derived.
+"""
+
+from .shape import Shape, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
+
+from .polygon import Polygon
+from .circle import Circle
+from .ellipse import Ellipse
+from .arc import Arc
+from .text import Text
diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py
new file mode 100644
index 0000000..74f0ec0
--- /dev/null
+++ b/masque/shapes/arc.py
@@ -0,0 +1,358 @@
+from typing import List
+import math
+import numpy
+from numpy import pi
+
+from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
+from .. import PatternError
+from ..utils import is_scalar, vector2
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+class Arc(Shape):
+    """
+    An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
+     center. It has a position, two radii, a start and stop angle, a rotation, and a width.
+
+    The radii define an ellipse; the ring is formed with radii +/- width/2.
+    The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
+    The start and stop angle are measured counterclockwise from the first (x) radius.
+    """
+
+    _radii = None           # type: numpy.ndarray
+    _angles = None          # type: numpy.ndarray
+    _width = 1.0            # type: float
+    _rotation = 0.0         # type: float
+
+    # Defaults for to_polygons
+    poly_num_points = DEFAULT_POLY_NUM_POINTS   # type: int
+    poly_max_arclen = None                      # type: float
+
+    # radius properties
+    @property
+    def radii(self) -> numpy.ndarray:
+        """
+        Return the radii [rx, ry]
+
+        :return: [rx, ry]
+        """
+        return self._radii
+
+    @radii.setter
+    def radii(self, val: vector2):
+        val = numpy.array(val, dtype=float).flatten()
+        if not val.size == 2:
+            raise PatternError('Radii must have length 2')
+        if not val.min() >= 0:
+            raise PatternError('Radii must be non-negative')
+        self._radii = val
+
+    @property
+    def radius_x(self) -> float:
+        return self._radii[0]
+
+    @radius_x.setter
+    def radius_x(self, val: float):
+        if not val >= 0:
+            raise PatternError('Radius must be non-negative')
+        self._radii[0] = val
+
+    @property
+    def radius_y(self) -> float:
+        return self._radii[1]
+
+    @radius_y.setter
+    def radius_y(self, val: float):
+        if not val >= 0:
+            raise PatternError('Radius must be non-negative')
+        self._radii[1] = val
+
+    # arc start/stop angle properties
+    @property
+    def angles(self) -> vector2:
+        """
+        Return the start and stop angles [a_start, a_stop].
+        Angles are measured from x-axis after rotation
+
+        :return: [a_start, a_stop]
+        """
+        return self._angles
+
+    @angles.setter
+    def angles(self, val: vector2):
+        val = numpy.array(val, dtype=float).flatten()
+        if not val.size == 2:
+            raise PatternError('Angles must have length 2')
+        self._angles = val
+
+    @property
+    def start_angle(self) -> float:
+        return self.angles[0]
+
+    @start_angle.setter
+    def start_angle(self, val: float):
+        self.angles = (val, self.angles[1])
+
+    @property
+    def stop_angle(self) -> float:
+        return self.angles[1]
+
+    @stop_angle.setter
+    def stop_angle(self, val: float):
+        self.angles = (self.angles[0], val)
+
+    # Rotation property
+    @property
+    def rotation(self) -> float:
+        """
+        Rotation of radius_x from x_axis, counterclockwise, in radians. Stored mod 2*pi
+
+        :return: rotation counterclockwise in radians
+        """
+        return self._rotation
+
+    @rotation.setter
+    def rotation(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Rotation must be a scalar')
+        self._rotation = val % (2 * pi)
+
+    # Width
+    @property
+    def width(self) -> float:
+        """
+        Width of the arc (difference between inner and outer radii)
+
+        :return: width
+        """
+        return self._width
+
+    @width.setter
+    def width(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Width must be a scalar')
+        if not val > 0:
+            raise PatternError('Width must be positive')
+        self._width = val
+
+    def __init__(self,
+                 radii: vector2,
+                 angles: vector2,
+                 width: float,
+                 rotation: float=0,
+                 poly_num_points: int=DEFAULT_POLY_NUM_POINTS,
+                 poly_max_arclen: float=None,
+                 offset: vector2=(0.0, 0.0),
+                 layer: int=0,
+                 dose: float=1.0):
+        self.offset = offset
+        self.layer = layer
+        self.dose = dose
+        self.radii = radii
+        self.angles = angles
+        self.width = width
+        self.rotation = rotation
+        self.poly_num_points = poly_num_points
+        self.poly_max_arclen = poly_max_arclen
+
+    def to_polygons(self, poly_num_points: int=None, poly_max_arclen: float=None) -> List[Polygon]:
+        if poly_num_points is None:
+            poly_num_points = self.poly_num_points
+        if poly_max_arclen is None:
+            poly_max_arclen = self.poly_max_arclen
+
+        if (poly_num_points is None) and (poly_max_arclen is None):
+            raise PatternError('Max number of points and arclength left unspecified' +
+                               ' (default was also overridden)')
+
+        r0, r1 = self.radii
+
+        # Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
+        a_ranges = self._angles_to_parameters()
+
+        # Approximate perimeter
+        # Ramanujan, S., "Modular Equations and Approximations to ,"
+        #  Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372
+        a0, a1 = a_ranges[1]    # use outer arc
+        h = ((r1 - r0) / (r1 + r0)) ** 2
+        ellipse_perimeter = pi * (r1 + r0) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
+        perimeter = abs(a0 - a1) / (2 * pi) * ellipse_perimeter         # TODO: make this more accurate
+
+        n = []
+        if poly_num_points is not None:
+            n += [poly_num_points]
+        if poly_max_arclen is not None:
+            n += [perimeter / poly_max_arclen]
+        thetas_inner = numpy.linspace(a_ranges[0][1], a_ranges[0][0], max(n), endpoint=True)
+        thetas_outer = numpy.linspace(a_ranges[1][0], a_ranges[1][1], max(n), endpoint=True)
+
+        sin_th_i, cos_th_i = (numpy.sin(thetas_inner), numpy.cos(thetas_inner))
+        sin_th_o, cos_th_o = (numpy.sin(thetas_outer), numpy.cos(thetas_outer))
+        wh = self.width / 2.0
+
+        xs1 = (r0 + wh) * cos_th_o
+        ys1 = (r1 + wh) * sin_th_o
+        xs2 = (r0 - wh) * cos_th_i
+        ys2 = (r1 - wh) * sin_th_i
+
+        xs = numpy.hstack((xs1, xs2))
+        ys = numpy.hstack((ys1, ys2))
+        xys = numpy.vstack((xs, ys)).T
+
+        poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset)
+        poly.rotate(self.rotation)
+        return [poly]
+
+    def get_bounds(self) -> numpy.ndarray:
+        '''
+        Equation for rotated ellipse is
+            x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)
+            y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)
+          where t is our parameter.
+
+        Differentiating and solving for 0 slope wrt. t, we find
+            tan(t) = -+ b/a cot(phi)
+          where -+ is for x, y cases, so that's where the extrema are.
+
+        If the extrema are innaccessible due to arc constraints, check the arc endpoints instead.
+        '''
+        a_ranges = self._angles_to_parameters()
+
+        mins = []
+        maxs = []
+        for a, sgn in zip(a_ranges, (-1, +1)):
+            wh = sgn * self.width/2
+            rx = self.radius_x + wh
+            ry = self.radius_y + wh
+
+            a0, a1 = a
+            a0_offset = a0 - (a0 % (2 * pi))
+
+            sin_r = numpy.sin(self.rotation)
+            cos_r = numpy.cos(self.rotation)
+            sin_a = numpy.sin(a)
+            cos_a = numpy.cos(a)
+
+            # Cutoff angles
+            xpt = (-self.rotation) % (2 * pi) + a0_offset
+            ypt = (pi/2 - self.rotation) % (2 * pi) + a0_offset
+            xnt = (xpt - pi) % (2 * pi) + a0_offset
+            ynt = (ypt - pi) % (2 * pi) + a0_offset
+
+            # Points along coordinate axes
+            rx2_inv = 1 / (rx * rx)
+            ry2_inv = 1 / (ry * ry)
+            xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5
+            yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5
+
+            # Arc endpoints
+            xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
+            yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
+
+            # If our arc subtends a coordinate axis, use the extremum along that axis
+            if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
+                xp = xr
+
+            if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
+                xn = -xr
+
+            if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
+                yp = yr
+
+            if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
+                yn = -yr
+
+            mins.append([xn, yn])
+            maxs.append([xp, yp])
+        return numpy.vstack((numpy.min(mins, axis=0) + self.offset,
+                             numpy.max(maxs, axis=0) + self.offset))
+
+    def rotate(self, theta: float) -> 'Arc':
+        self.rotation += theta
+        return self
+
+    def mirror(self, axis: int) -> 'Arc':
+        self.offset[axis - 1] *= -1
+        self.rotation *= -1
+        self.angles *= -1
+        return self
+
+    def scale_by(self, c: float) -> 'Arc':
+        self.radii *= c
+        self.width *= c
+        return self
+
+    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
+        if self.radius_x < self.radius_y:
+            radii = self.radii / self.radius_x
+            scale = self.radius_x
+            rotation = self.rotation
+            angles = self.angles
+        else:  # rotate by 90 degrees and swap radii
+            radii = self.radii[::-1] / self.radius_y
+            scale = self.radius_y
+            rotation = self.rotation + pi / 2
+            angles = self.angles - pi / 2
+
+        delta_angle = angles[1] - angles[0]
+        start_angle = angles[0] % (2 * pi)
+        if start_angle >= pi:
+            start_angle -= pi
+            rotation += pi
+
+        angles = (start_angle, start_angle + delta_angle)
+        rotation %= 2 * pi
+        width = self.width
+
+        return (type(self), radii, angles, width, self.layer), \
+               (self.offset, scale/norm_value, rotation, self.dose), \
+               lambda: Arc(radii=radii*norm_value, angles=angles, width=width, layer=self.layer)
+
+    def get_cap_edges(self) -> numpy.ndarray:
+        '''
+        :returns: [[[x0, y0], [x1, y1]],   array of 4 points, specifying the two cuts which
+                   [[x2, y2], [x3, y3]]],    would create this arc from its corresponding ellipse.
+        '''
+        a_ranges = self._angles_to_parameters()
+
+        mins = []
+        maxs = []
+        for a, sgn in zip(a_ranges, (-1, +1)):
+            wh = sgn * self.width/2
+            rx = self.radius_x + wh
+            ry = self.radius_y + wh
+
+            sin_r = numpy.sin(self.rotation)
+            cos_r = numpy.cos(self.rotation)
+            sin_a = numpy.sin(a)
+            cos_a = numpy.cos(a)
+
+            # arc endpoints
+            xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
+            yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
+
+            mins.append([xn, yn])
+            maxs.append([xp, yp])
+        return  numpy.array([mins, maxs]) + self.offset
+
+    def _angles_to_parameters(self) -> numpy.ndarray:
+        '''
+        :return: "Eccentric anomaly" parameter ranges for the inner and outer edges, in the form
+                   [[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]
+        '''
+        a = []
+        for sgn in (-1, +1):
+            wh = sgn * self.width/2
+            rx = self.radius_x + wh
+            ry = self.radius_y + wh
+
+            # create paremeter 'a' for parametrized ellipse
+            a0, a1 = (numpy.arctan2(rx*numpy.sin(a), ry*numpy.cos(a)) for a in self.angles)
+            sign = numpy.sign(self.angles[1] - self.angles[0])
+            if sign != numpy.sign(a1 - a0):
+                a1 += sign * 2 * pi
+
+            a.append((a0, a1))
+        return numpy.array(a)
diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py
new file mode 100644
index 0000000..489a608
--- /dev/null
+++ b/masque/shapes/circle.py
@@ -0,0 +1,99 @@
+from typing import List
+import numpy
+from numpy import pi
+
+from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
+from .. import PatternError
+from ..utils import is_scalar, vector2
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+class Circle(Shape):
+    """
+    A circle, which has a position and radius.
+    """
+
+    _radius = None                              # type: float
+
+    # Defaults for to_polygons
+    poly_num_points = DEFAULT_POLY_NUM_POINTS   # type: int
+    poly_max_arclen = None                      # type: float
+
+    # radius property
+    @property
+    def radius(self) -> float:
+        """
+        Circle's radius (float, >= 0)
+
+        :return: radius
+        """
+        return self._radius
+
+    @radius.setter
+    def radius(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Radius must be a scalar')
+        if not val >= 0:
+            raise PatternError('Radius must be non-negative')
+        self._radius = val
+
+    def __init__(self,
+                 radius: float,
+                 poly_num_points: int=DEFAULT_POLY_NUM_POINTS,
+                 poly_max_arclen: float=None,
+                 offset: vector2=(0.0, 0.0),
+                 layer: int=0,
+                 dose: float=1.0):
+        self.offset = numpy.array(offset, dtype=float)
+        self.layer = layer
+        self.dose = dose
+        self.radius = radius
+        self.poly_num_points = poly_num_points
+        self.poly_max_arclen = poly_max_arclen
+
+    def to_polygons(self, poly_num_points: int=None, poly_max_arclen: float=None) -> List[Polygon]:
+        if poly_num_points is None:
+            poly_num_points = self.poly_num_points
+        if poly_max_arclen is None:
+            poly_max_arclen = self.poly_max_arclen
+
+        if (poly_num_points is None) and (poly_max_arclen is None):
+            raise PatternError('Number of points and arclength left '
+                               'unspecified (default was also overridden)')
+
+        n = []
+        if poly_num_points is not None:
+            n += [poly_num_points]
+        if poly_max_arclen is not None:
+            n += [2 * pi * self.radius / poly_max_arclen]
+        thetas = numpy.linspace(2 * pi, 0, max(n), endpoint=False)
+        xs = numpy.cos(thetas) * self.radius
+        ys = numpy.sin(thetas) * self.radius
+        xys = numpy.vstack((xs, ys)).T
+
+        return [Polygon(xys, offset=self.offset, dose=self.dose, layer=self.layer)]
+
+    def get_bounds(self) -> numpy.ndarray:
+        return numpy.vstack((self.offset - self.radius,
+                             self.offset + self.radius))
+
+    def rotate(self, theta: float) -> 'Circle':
+        return self
+
+    def mirror(self, axis: int) -> 'Circle':
+        self.offset *= -1
+        return self
+
+    def scale_by(self, c: float) -> 'Circle':
+        self.radius *= c
+        return self
+
+    def normalized_form(self, norm_value) -> normalized_shape_tuple:
+        rotation = 0.0
+        magnitude = self.radius / norm_value
+        return (type(self), self.layer), \
+               (self.offset, magnitude, rotation, self.dose), \
+               lambda: Circle(radius=norm_value, layer=self.layer)
+
diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py
new file mode 100644
index 0000000..6b7317f
--- /dev/null
+++ b/masque/shapes/ellipse.py
@@ -0,0 +1,166 @@
+from typing import List
+import math
+import numpy
+from numpy import pi
+
+from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
+from .. import PatternError
+from ..utils import is_scalar, rotation_matrix_2d, vector2
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+class Ellipse(Shape):
+    """
+    An ellipse, which has a position, two radii, and a rotation.
+    The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
+    """
+
+    _radii = None       # type: numpy.ndarray
+    _rotation = 0.0     # type: float
+
+    # Defaults for to_polygons
+    poly_num_points = DEFAULT_POLY_NUM_POINTS   # type: int
+    poly_max_arclen = None                      # type: float
+
+    # radius properties
+    @property
+    def radii(self) -> numpy.ndarray:
+        """
+        Return the radii [rx, ry]
+
+        :return: [rx, ry]
+        """
+        return self._radii
+
+    @radii.setter
+    def radii(self, val: vector2):
+        val = numpy.array(val).flatten()
+        if not val.size == 2:
+            raise PatternError('Radii must have length 2')
+        if not val.min() >= 0:
+            raise PatternError('Radii must be non-negative')
+        self._radii = val
+
+    @property
+    def radius_x(self) -> float:
+        return self.radii[0]
+
+    @radius_x.setter
+    def radius_x(self, val: float):
+        if not val >= 0:
+            raise PatternError('Radius must be non-negative')
+        self.radii[0] = val
+
+    @property
+    def radius_y(self) -> float:
+        return self.radii[1]
+
+    @radius_y.setter
+    def radius_y(self, val: float):
+        if not val >= 0:
+            raise PatternError('Radius must be non-negative')
+        self.radii[1] = val
+
+    # Rotation property
+    @property
+    def rotation(self) -> float:
+        """
+        Rotation of rx from the x axis. Uses the interval [0, pi) in radians (counterclockwise
+         is positive)
+
+        :return: counterclockwise rotation in radians
+        """
+        return self._rotation
+
+    @rotation.setter
+    def rotation(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Rotation must be a scalar')
+        self._rotation = val % pi
+
+    def __init__(self,
+                 radii: vector2,
+                 rotation: float=0,
+                 poly_num_points: int=DEFAULT_POLY_NUM_POINTS,
+                 poly_max_arclen: float=None,
+                 offset: vector2=(0.0, 0.0),
+                 layer: int=0,
+                 dose: float=1.0):
+        self.offset = offset
+        self.layer = layer
+        self.dose = dose
+        self.radii = radii
+        self.rotation = rotation
+        self.poly_num_points = poly_num_points
+        self.poly_max_arclen = poly_max_arclen
+
+    def to_polygons(self,
+                    poly_num_points: int=None,
+                    poly_max_arclen: float=None
+                    ) -> List[Polygon]:
+        if poly_num_points is None:
+            poly_num_points = self.poly_num_points
+        if poly_max_arclen is None:
+            poly_max_arclen = self.poly_max_arclen
+
+        if (poly_num_points is None) and (poly_max_arclen is None):
+            raise PatternError('Number of points and arclength left unspecified'
+                               ' (default was also overridden)')
+
+        r0, r1 = self.radii
+
+        # Approximate perimeter
+        # Ramanujan, S., "Modular Equations and Approximations to ,"
+        #  Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372
+        h = ((r1 - r0) / (r1 + r0)) ** 2
+        perimeter = pi * (r1 + r0) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
+
+        n = []
+        if poly_num_points is not None:
+            n += [poly_num_points]
+        if poly_max_arclen is not None:
+            n += [perimeter / poly_max_arclen]
+        thetas = numpy.linspace(2 * pi, 0, max(n), endpoint=False)
+
+        sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
+        xs = r0 * cos_th
+        ys = r1 * sin_th
+        xys = numpy.vstack((xs, ys)).T
+
+        poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset)
+        poly.rotate(self.rotation)
+        return [poly]
+
+    def get_bounds(self) -> numpy.ndarray:
+        rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii)
+        return numpy.vstack((self.offset - rot_radii[0],
+                             self.offset + rot_radii[1]))
+
+    def rotate(self, theta: float) -> 'Ellipse':
+        self.rotation += theta
+        return self
+
+    def mirror(self, axis: int) -> 'Ellipse':
+        self.offset[axis - 1] *= -1
+        self.rotation *= -1
+        return self
+
+    def scale_by(self, c: float) -> 'Ellipse':
+        self.radii *= c
+        return self
+
+    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
+        if self.radius_x < self.radius_y:
+            radii = self.radii / self.radius_x
+            scale = self.radius_x
+            angle = self.rotation
+        else:
+            radii = self.radii[::-1] / self.radius_y
+            scale = self.radius_y
+            angle = (self.rotation + pi / 2) % pi
+        return (type(self), radii, self.layer), \
+               (self.offset, scale/norm_value, angle, self.dose), \
+               lambda: Ellipse(radii=radii*norm_value, layer=self.layer)
+
diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py
new file mode 100644
index 0000000..4bc2a14
--- /dev/null
+++ b/masque/shapes/polygon.py
@@ -0,0 +1,295 @@
+from typing import List
+import copy
+import numpy
+from numpy import pi
+
+from . import Shape, normalized_shape_tuple
+from .. import PatternError
+from ..utils import is_scalar, rotation_matrix_2d, vector2
+
+__author__ = 'Jan Petykiewicz'
+
+
+class Polygon(Shape):
+    """
+    A polygon, consisting of a bunch of vertices (Nx2 ndarray) along with an offset.
+
+    A normalized_form(...) is available, but can be quite slow with lots of vertices.
+    """
+    _vertices = None        # type: numpy.ndarray
+
+    # vertices property
+    @property
+    def vertices(self) -> numpy.ndarray:
+        """
+        Vertices of the polygon (Nx2 ndarray: [[x0, y0], [x1, y1], ...]
+
+        :return: vertices
+        """
+        return self._vertices
+
+    @vertices.setter
+    def vertices(self, val: numpy.ndarray):
+        val = numpy.array(val, dtype=float)
+        if len(val.shape) < 2 or val.shape[1] != 2:
+            raise PatternError('Vertices must be an Nx2 array')
+        if val.shape[0] < 3:
+            raise PatternError('Must have at least 3 vertices (Nx2, N>3)')
+        self._vertices = val
+
+    # xs property
+    @property
+    def xs(self) -> numpy.ndarray:
+        """
+        All x vertices in a 1D ndarray
+        """
+        return self.vertices[:, 0]
+
+    @xs.setter
+    def xs(self, val: numpy.ndarray):
+        val = numpy.array(val, dtype=float).flatten()
+        if val.size != self.vertices.shape[0]:
+            raise PatternError('Wrong number of vertices')
+        self.vertices[:, 0] = val
+
+    # ys property
+    @property
+    def ys(self) -> numpy.ndarray:
+        """
+        All y vertices in a 1D ndarray
+        """
+        return self.vertices[:, 1]
+
+    @ys.setter
+    def ys(self, val: numpy.ndarray):
+        val = numpy.array(val, dtype=float).flatten()
+        if val.size != self.vertices.shape[0]:
+            raise PatternError('Wrong number of vertices')
+        self.vertices[:, 1] = val
+
+    def __init__(self,
+                 vertices: numpy.ndarray,
+                 offset: vector2=(0.0, 0.0),
+                 layer: int=0,
+                 dose: float=1.0):
+        self.offset = offset
+        self.layer = layer
+        self.dose = dose
+        self.vertices = vertices
+
+    @staticmethod
+    def square(side_length: float,
+               rotation: float=0.0,
+               offset: vector2=(0.0, 0.0),
+               layer: int=0,
+               dose: float=1.0
+               ) -> 'Polygon':
+        """
+        Draw a square given side_length, centered on the origin.
+
+        :param side_length: Length of one side
+        :param rotation: Rotation counterclockwise, in radians
+        :param offset: Offset, default (0, 0)
+        :param layer: Layer, default 0
+        :param dose: Dose, default 1.0
+        :return: A Polygon object containing the requested square
+        """
+        norm_square = numpy.array([[-1, -1],
+                                   [-1, +1],
+                                   [+1, +1],
+                                   [+1, -1]], dtype=float)
+        vertices = 0.5 * side_length * norm_square
+        poly = Polygon(vertices, offset, layer, dose)
+        poly.rotate(rotation)
+        return poly
+
+    @staticmethod
+    def rectangle(lx: float,
+                  ly: float,
+                  rotation: float=0,
+                  offset: vector2=(0.0, 0.0),
+                  layer: int=0,
+                  dose: float=1.0
+                  ) -> 'Polygon':
+        """
+        Draw a rectangle with side lengths lx and ly, centered on the origin.
+
+        :param lx: Length along x (before rotation)
+        :param ly: Length along y (before rotation)
+        :param rotation: Rotation counterclockwise, in radians
+        :param offset: Offset, default (0, 0)
+        :param layer: Layer, default 0
+        :param dose: Dose, default 1.0
+        :return: A Polygon object containing the requested rectangle
+        """
+        vertices = 0.5 * numpy.array([[-lx, -ly],
+                                      [-lx, +ly],
+                                      [+lx, +ly],
+                                      [+lx, -ly]], dtype=float)
+        poly = Polygon(vertices, offset, layer, dose)
+        poly.rotate(rotation)
+        return poly
+
+    @staticmethod
+    def rect(xmin: float = None,
+             xctr: float = None,
+             xmax: float = None,
+             lx: float = None,
+             ymin: float = None,
+             yctr: float = None,
+             ymax: float = None,
+             ly: float = None,
+             layer: int = 0,
+             dose: float = 1.0
+             ) -> 'Polygon':
+        """
+        Draw a rectangle by specifying side/center positions.
+
+        Must provide 2 of (xmin, xctr, xmax, lx),
+        and 2 of (ymin, yctr, ymax, ly).
+
+        :param xmin: Minimum x coordinate
+        :param xctr: Center x coordinate
+        :param xmax: Maximum x coordinate
+        :param lx: Length along x direction
+        :param ymin: Minimum y coordinate
+        :param yctr: Center y coordinate
+        :param ymax: Maximum y coordinate
+        :param yx: Length along y direction
+        :param layer: Layer, default 0
+        :param dose: Dose, default 1.0
+        :return: A Polygon object containing the requested rectangle
+        """
+        if lx is None:
+            if xctr is None:
+                xctr = 0.5 * (xmax + xmin)
+                lx = xmax - xmin
+            elif xmax is None:
+                lx = 2 * (xctr - xmin)
+            elif xmin is None:
+                lx = 2 * (xmax - xctr)
+            else:
+                raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
+        else:
+            if xctr is not None:
+                pass
+            elif xmax is None:
+                xctr = xmin + 0.5 * lx
+            elif xmin is None:
+                xctr = xmax - 0.5 * lx
+            else:
+                raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
+
+        if ly is None:
+            if yctr is None:
+                yctr = 0.5 * (ymax + ymin)
+                ly = ymax - ymin
+            elif ymax is None:
+                ly = 2 * (yctr - ymin)
+            elif ymin is None:
+                ly = 2 * (ymax - yctr)
+            else:
+                raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
+        else:
+            if yctr is not None:
+                pass
+            elif ymax is None:
+                yctr = ymin + 0.5 * ly
+            elif ymin is None:
+                yctr = ymax - 0.5 * ly
+            else:
+                raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
+
+        poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr),
+                                 layer=layer, dose=dose)
+        return poly
+
+
+    def to_polygons(self,
+                    _poly_num_points: int=None,
+                    _poly_max_arclen: float=None,
+                    ) -> List['Polygon']:
+        return [copy.deepcopy(self)]
+
+    def get_bounds(self) -> numpy.ndarray:
+        return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0),
+                             self.offset + numpy.max(self.vertices, axis=0)))
+
+    def rotate(self, theta: float) -> 'Polygon':
+        self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
+        return self
+
+    def mirror(self, axis: int) -> 'Polygon':
+        self.vertices[:, axis - 1] *= -1
+        return self
+
+    def scale_by(self, c: float) -> 'Polygon':
+        self.vertices *= c
+        return self
+
+    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
+        # Note: this function is going to be pretty slow for many-vertexed polygons, relative to
+        #   other shapes
+        offset = self.vertices.mean(axis=0) + self.offset
+        zeroed_vertices = self.vertices - offset
+
+        scale = zeroed_vertices.std()
+        normed_vertices = zeroed_vertices / scale
+
+        _, _, vertex_axis = numpy.linalg.svd(zeroed_vertices)
+        rotation = numpy.arctan2(vertex_axis[0][1], vertex_axis[0][0]) % (2 * pi)
+        rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
+                                        for v in normed_vertices])
+
+        # Reorder the vertices so that the one with lowest x, then y, comes first.
+        x_min = rotated_vertices[:, 0].argmin()
+        if not is_scalar(x_min):
+            y_min = rotated_vertices[x_min, 1].argmin()
+            x_min = x_min[y_min]
+        reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
+
+        return (type(self), reordered_vertices.data.tobytes(), self.layer), \
+               (offset, scale/norm_value, rotation, self.dose), \
+               lambda: Polygon(reordered_vertices*norm_value, layer=self.layer)
+
+    def clean_vertices(self) -> 'Polygon':
+        """
+        Removes duplicate, co-linear and otherwise redundant vertices.
+
+        :returns: self
+        """
+        self.remove_colinear_vertices()
+        return self
+
+    def remove_duplicate_vertices(self) -> 'Polygon':
+        '''
+        Removes all consecutive duplicate (repeated) vertices.
+
+        :returns: self
+        '''
+        duplicates = (self.vertices == numpy.roll(self.vertices, 1, axis=0)).all(axis=1)
+        self.vertices = self.vertices[~duplicates]
+        return self
+
+    def remove_colinear_vertices(self) -> 'Polygon':
+        '''
+        Removes consecutive co-linear vertices.
+
+        :returns: self
+        '''
+        dv0 = numpy.roll(self.vertices, 1, axis=0) - self.vertices
+        dv1 = numpy.roll(dv0, -1, axis=0)
+
+        # find cases where at least one coordinate is 0 in successive dv's
+        eq = dv1 == dv0
+        aa_colinear = numpy.logical_and(eq, dv0 == 0).any(axis=1)
+
+        # find cases where slope is equal
+        with numpy.errstate(divide='ignore', invalid='ignore'):   # don't care about zeroes
+            slope_quotient = (dv0[:, 0] * dv1[:, 1]) / (dv1[:, 0] * dv0[:, 1])
+        slopes_equal = numpy.abs(slope_quotient - 1) < 1e-14
+
+        colinear = numpy.logical_or(aa_colinear, slopes_equal)
+        self.vertices = self.vertices[~colinear]
+        return self
+
diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py
new file mode 100644
index 0000000..45eb35f
--- /dev/null
+++ b/masque/shapes/shape.py
@@ -0,0 +1,381 @@
+from typing import List, Tuple, Callable
+from abc import ABCMeta, abstractmethod
+import copy
+import numpy
+
+from .. import PatternError
+from ..utils import is_scalar, rotation_matrix_2d, vector2
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+# Type definitions
+normalized_shape_tuple = Tuple[Tuple,
+                               Tuple[numpy.ndarray, float, float, float],
+                               Callable[[], 'Shape']]
+
+# ## Module-wide defaults
+# Default number of points per polygon for shapes
+DEFAULT_POLY_NUM_POINTS = 24
+
+
+class Shape(metaclass=ABCMeta):
+    """
+    Abstract class specifying functions common to all shapes.
+    """
+
+    # [x_offset, y_offset]
+    _offset = numpy.array([0.0, 0.0])   # type: numpy.ndarray
+
+    # Layer (integer >= 0 or tuple)
+    _layer = 0                          # type: int or Tuple
+
+    # Dose
+    _dose = 1.0                         # type: float
+
+    # --- Abstract methods
+    @abstractmethod
+    def to_polygons(self, num_vertices: int, max_arclen: float) -> List['Polygon']:
+        """
+        Returns a list of polygons which approximate the shape.
+
+        :param num_vertices: Number of points to use for each polygon. Can be overridden by
+                     max_arclen if that results in more points. Optional, defaults to shapes'
+                      internal defaults.
+        :param max_arclen: Maximum arclength which can be approximated by a single line
+                     segment. Optional, defaults to shapes' internal defaults.
+        :return: List of polygons equivalent to the shape
+        """
+        pass
+
+    @abstractmethod
+    def get_bounds(self) -> numpy.ndarray:
+        """
+        Returns [[x_min, y_min], [x_max, y_max]] which specify a minimal bounding box for the shape.
+
+        :return: [[x_min, y_min], [x_max, y_max]]
+        """
+        pass
+
+    @abstractmethod
+    def rotate(self, theta: float) -> 'Shape':
+        """
+        Rotate the shape around its center (0, 0), ignoring its offset.
+
+        :param theta: Angle to rotate by (counterclockwise, radians)
+        :return: self
+        """
+        pass
+
+    @abstractmethod
+    def mirror(self, axis: int) -> 'Shape':
+        """
+        Mirror the shape across an axis.
+
+        :param axis: Axis to mirror across.
+        :return: self
+        """
+        pass
+
+    @abstractmethod
+    def scale_by(self, c: float) -> 'Shape':
+        """
+        Scale the shape's size (eg. radius, for a circle) by a constant factor.
+
+        :param c: Factor to scale by
+        :return: self
+        """
+        pass
+
+    @abstractmethod
+    def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
+        """
+        Writes the shape in a standardized notation, with offset, scale, rotation, and dose
+         information separated out from the remaining values.
+
+        :param norm_value: This value is used to normalize lengths intrinsic to teh shape;
+                eg. for a circle, the returned magnitude value will be (radius / norm_value), and
+                the returned callable will create a Circle(radius=norm_value, ...). This is useful
+                when you find it important for quantities to remain in a certain range, eg. for
+                GDSII where vertex locations are stored as integers.
+        :return: The returned information takes the form of a 3-element tuple,
+                (intrinsic, extrinsic, constructor). These are further broken down as:
+                extrinsic: ([x_offset, y_offset], scale, rotation, dose)
+                intrinsic: A tuple of basic types containing all information about the instance that
+                            is not contained in 'extrinsic'. Usually, intrinsic[0] == type(self).
+                constructor: A callable (no arguments) which returns an instance of type(self) with
+                            internal state equivalent to 'intrinsic'.
+        """
+        pass
+
+    # ---- Non-abstract properties
+    # offset property
+    @property
+    def offset(self) -> numpy.ndarray:
+        """
+        [x, y] offset
+
+        :return: [x_offset, y_offset]
+        """
+        return self._offset
+
+    @offset.setter
+    def offset(self, val: vector2):
+        if not isinstance(val, numpy.ndarray):
+            val = numpy.array(val, dtype=float)
+
+        if val.size != 2:
+            raise PatternError('Offset must be convertible to size-2 ndarray')
+        self._offset = val.flatten()
+
+    # layer property
+    @property
+    def layer(self) -> int or Tuple[int]:
+        """
+        Layer number (int or tuple of ints)
+
+        :return: Layer
+        """
+        return self._layer
+
+    @layer.setter
+    def layer(self, val: int or List[int]):
+        self._layer = val
+
+    # dose property
+    @property
+    def dose(self) -> float:
+        """
+        Dose (float >= 0)
+
+        :return: Dose value
+        """
+        return self._dose
+
+    @dose.setter
+    def dose(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Dose must be a scalar')
+        if not val >= 0:
+            raise PatternError('Dose must be non-negative')
+        self._dose = val
+
+    # ---- Non-abstract methods
+    def copy(self) -> 'Shape':
+        """
+        Returns a deep copy of the shape.
+
+        :return: Deep copy of self
+        """
+        return copy.deepcopy(self)
+
+    def translate(self, offset: vector2) -> 'Shape':
+        """
+        Translate the shape by the given offset
+
+        :param offset: [x_offset, y,offset]
+        :return: self
+        """
+        self.offset += offset
+        return self
+
+    def rotate_around(self, pivot: vector2, rotation: float) -> 'Shape':
+        """
+        Rotate the shape around a point.
+
+        :param pivot: Point (x, y) to rotate around
+        :param rotation: Angle to rotate by (counterclockwise, radians)
+        :return: self
+        """
+        pivot = numpy.array(pivot, dtype=float)
+        self.translate(-pivot)
+        self.rotate(rotation)
+        self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
+        self.translate(+pivot)
+        return self
+
+    def manhattanize_fast(self, grid_x: numpy.ndarray, grid_y: numpy.ndarray) -> List['Polygon']:
+        """
+        Returns a list of polygons with grid-aligned ("Manhattan") edges approximating the shape.
+
+        This function works by
+            1) Converting the shape to polygons using .to_polygons()
+            2) Approximating each edge with an equivalent Manhattan edge
+        This process results in a reasonable Manhattan representation of the shape, but is
+          imprecise near non-Manhattan or off-grid corners.
+
+        :param grid_x: List of allowed x-coordinates for the Manhattanized polygon edges.
+        :param grid_y: List of allowed y-coordinates for the Manhattanized polygon edges.
+        :return: List of Polygon objects with grid-aligned edges.
+        """
+        from . import Polygon
+
+        grid_x = numpy.unique(grid_x)
+        grid_y = numpy.unique(grid_y)
+
+        polygon_contours = []
+        for polygon in self.to_polygons():
+            mins, maxs = polygon.get_bounds()
+
+            vertex_lists = []
+            p_verts = polygon.vertices + polygon.offset
+            for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0)):
+                dv = v_next - v
+
+                if abs(dv[0]) < 1e-20:
+                    xs = numpy.array([v[0], v[0]])   # TODO maybe pick between v[0] and v_next[0]?
+                    ys = numpy.array([v[1], v_next[1]])
+                    xi = numpy.digitize(xs, grid_x).clip(1, len(grid_x) - 1)
+                    yi = numpy.digitize(ys, grid_y).clip(1, len(grid_y) - 1)
+                    err_x = (xs - grid_x[xi]) / (grid_x[xi] - grid_x[xi - 1])
+                    err_y = (ys - grid_y[yi]) / (grid_y[yi] - grid_y[yi - 1])
+                    xi[err_y < 0.5] -= 1
+                    yi[err_y < 0.5] -= 1
+
+                    segment = numpy.column_stack((grid_x[xi], grid_y[yi]))
+                    vertex_lists.append(segment)
+                    continue
+
+                m = dv[1]/dv[0]
+                def get_grid_inds(xes):
+                    ys = m * (xes - v[0]) + v[1]
+
+                    # (inds - 1) is the index of the y-grid line below the edge's intersection with the x-grid
+                    inds = numpy.digitize(ys, grid_y).clip(1, len(grid_y) - 1)
+
+                    # err is what fraction of the cell upwards we have to go to reach our y
+                    #   (can be negative at bottom edge due to clip above)
+                    err = (ys - grid_y[inds - 1]) / (grid_y[inds] - grid_y[inds - 1])
+
+                    # now set inds to the index of the nearest y-grid line
+                    inds[err < 0.5] -= 1
+                    #if dv[0] >= 0:
+                    #    inds[err <= 0.5] -= 1
+                    #else:
+                    #    inds[err < 0.5] -= 1
+                    return inds
+
+                gxi_range = numpy.digitize([v[0], v_next[0]], grid_x)
+                gxi_min = numpy.min(gxi_range - 1).clip(0, len(grid_x))
+                gxi_max = numpy.max(gxi_range).clip(0, len(grid_x))
+
+                xs = grid_x[gxi_min:gxi_max]
+                inds = get_grid_inds(xs)
+
+                # Find intersections for midpoints
+                xs2 = (xs[:-1] + xs[1:]) / 2
+                inds2 = get_grid_inds(xs2)
+
+                xinds = numpy.round(numpy.arange(gxi_min, gxi_max - 0.99, 1/3)).astype(int)
+
+                # interleave the results
+                yinds = xinds.copy()
+                yinds[0::3] = inds
+                yinds[1::3] = inds2
+                yinds[2::3] = inds2
+
+                vlist = numpy.column_stack((grid_x[xinds], grid_y[yinds]))
+                if dv[0] < 0:
+                    vlist = vlist[::-1]
+
+                vertex_lists.append(vlist)
+            polygon_contours.append(numpy.vstack(vertex_lists))
+
+        manhattan_polygons = []
+        for contour in polygon_contours:
+            manhattan_polygons.append(Polygon(
+                vertices=contour,
+                layer=self.layer,
+                dose=self.dose))
+
+        return manhattan_polygons
+
+
+    def manhattanize(self, grid_x: numpy.ndarray, grid_y: numpy.ndarray) -> List['Polygon']:
+        """
+        Returns a list of polygons with grid-aligned ("Manhattan") edges approximating the shape.
+
+        This function works by
+            1) Converting the shape to polygons using .to_polygons()
+            2) Accurately rasterizing each polygon on a grid,
+                where the edges of each grid cell correspond to the allowed coordinates
+            3) Thresholding the (anti-aliased) rasterized image
+            4) Finding the contours which outline the filled areas in the thresholded image
+        This process results in a fairly accurate Manhattan representation of the shape. Possible
+          caveats include:
+            a) If high accuracy is important, perform any polygonization and clipping operations
+                prior to calling this function. This allows you to specify any arguments you may
+                need for .to_polygons(), and also avoids calling .manhattanize() multiple times for
+                the same grid location (which causes inaccuracies in the final representation).
+            b) If the shape is very large or the grid very fine, memory requirements can be reduced
+                by breaking the shape apart into multiple, smaller shapes.
+            c) Inaccuracies in edge shape can result from Manhattanization of edges which are
+                equidistant from allowed edge location.
+
+        Implementation notes:
+            i) Rasterization is performed using float_raster, giving a high-precision anti-aliased
+                rasterized image.
+            ii) To find the exact polygon edges, the thresholded rasterized image is supersampled
+                  prior to calling skimage.measure.find_contours(), which uses marching squares
+                  to find the contours. This is done because find_contours() performs interpolation,
+                  which has to be undone in order to regain the axis-aligned contours. A targetted
+                  rewrite of find_contours() for this specific application, or use of a different
+                  boundary tracing method could remove this requirement, but for now this seems to
+                  be the most performant approach.
+
+        :param grid_x: List of allowed x-coordinates for the Manhattanized polygon edges.
+        :param grid_y: List of allowed y-coordinates for the Manhattanized polygon edges.
+        :return: List of Polygon objects with grid-aligned edges.
+        """
+        from . import Polygon
+        import skimage.measure
+        import float_raster
+
+        grid_x = numpy.unique(grid_x)
+        grid_y = numpy.unique(grid_y)
+
+        polygon_contours = []
+        for polygon in self.to_polygons():
+            # Get rid of unused gridlines (anything not within 2 lines of the polygon bounds)
+            mins, maxs = polygon.get_bounds()
+            keep_x = numpy.logical_and(grid_x > mins[0], grid_x < maxs[0])
+            keep_y = numpy.logical_and(grid_y > mins[1], grid_y < maxs[1])
+            for k in (keep_x, keep_y):
+                for s in (1, 2):
+                    k[s:] += k[:-s]
+                    k[:-s] += k[s:]
+                k = k > 0
+
+            gx = grid_x[keep_x]
+            gy = grid_y[keep_y]
+
+            if len(gx) == 0 or len(gy) == 0:
+                continue
+
+            offset = (numpy.where(keep_x)[0][0],
+                      numpy.where(keep_y)[0][0])
+
+            rastered = float_raster.raster((polygon.vertices + polygon.offset).T, gx, gy)
+            binary_rastered = (numpy.abs(rastered) >= 0.5)
+            supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1)
+
+            contours = skimage.measure.find_contours(supersampled, 0.5)
+            polygon_contours.append((offset, contours))
+
+        manhattan_polygons = []
+        for offset_i, contours in polygon_contours:
+            for contour in contours:
+                # /2 deals with supersampling
+                # +.5 deals with the fact that our 0-edge becomes -.5 in the super-sampled contour output
+                snapped_contour = numpy.round((contour + .5) / 2).astype(int)
+                vertices = numpy.hstack((grid_x[snapped_contour[:, None, 0] + offset_i[0]],
+                                         grid_y[snapped_contour[:, None, 1] + offset_i[1]]))
+
+                manhattan_polygons.append(Polygon(
+                    vertices=vertices,
+                    layer=self.layer,
+                    dose=self.dose))
+
+        return manhattan_polygons
+
diff --git a/masque/shapes/text.py b/masque/shapes/text.py
new file mode 100644
index 0000000..64b7468
--- /dev/null
+++ b/masque/shapes/text.py
@@ -0,0 +1,224 @@
+from typing import List, Tuple
+import numpy
+from numpy import pi, inf
+
+from . import Shape, Polygon, normalized_shape_tuple
+from .. import PatternError
+from ..utils import is_scalar, vector2, get_bit
+
+# Loaded on use:
+# from freetype import Face
+# from matplotlib.path import Path
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+class Text(Shape):
+    _string = ''
+    _height = 1.0
+    _rotation = 0.0
+    _mirrored = None
+    font_path = ''
+
+    # vertices property
+    @property
+    def string(self) -> str:
+        return self._string
+
+    @string.setter
+    def string(self, val: str):
+        self._string = val
+
+    # Rotation property
+    @property
+    def rotation(self) -> float:
+        return self._rotation
+
+    @rotation.setter
+    def rotation(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Rotation must be a scalar')
+        self._rotation = val % (2 * pi)
+
+    # Height property
+    @property
+    def height(self) -> float:
+        return self._height
+
+    @height.setter
+    def height(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Height must be a scalar')
+        self._height = val
+
+    # Mirrored property
+    @property
+    def mirrored(self) -> List[bool]:
+        return self._mirrored
+
+    @mirrored.setter
+    def mirrored(self, val: List[bool]):
+        if is_scalar(val):
+            raise PatternError('Mirrored must be a 2-element list of booleans')
+        self._mirrored = val
+
+    def __init__(self,
+                 string: str,
+                 height: float,
+                 font_path: str,
+                 mirrored: List[bool]=None,
+                 rotation: float=0.0,
+                 offset: vector2=(0.0, 0.0),
+                 layer: int=0,
+                 dose: float=1.0):
+        self.offset = offset
+        self.layer = layer
+        self.dose = dose
+        self.string = string
+        self.height = height
+        self.rotation = rotation
+        self.font_path = font_path
+        if mirrored is None:
+            mirrored = [False, False]
+        self.mirrored = mirrored
+
+    def to_polygons(self,
+                    _poly_num_points: int=None,
+                    _poly_max_arclen: float=None
+                    ) -> List[Polygon]:
+        all_polygons = []
+        total_advance = 0
+        for char in self.string:
+            raw_polys, advance = get_char_as_polygons(self.font_path, char)
+
+            # Move these polygons to the right of the previous letter
+            for xys in raw_polys:
+                poly = Polygon(xys, dose=self.dose, layer=self.layer)
+                [poly.mirror(ax) for ax, do in enumerate(self.mirrored) if do]
+                poly.scale_by(self.height)
+                poly.offset = self.offset + [total_advance, 0]
+                poly.rotate_around(self.offset, self.rotation)
+                all_polygons += [poly]
+
+            # Update the list of all polygons and how far to advance
+            total_advance += advance * self.height
+
+        return all_polygons
+
+    def rotate(self, theta: float) -> 'Text':
+        self.rotation += theta
+        return self
+
+    def mirror(self, axis: int) -> 'Text':
+        self.mirrored[axis] = not self.mirrored[axis]
+        return self
+
+    def scale_by(self, c: float) -> 'Text':
+        self.height *= c
+        return self
+
+    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
+        return (type(self), self.string, self.font_path, self.mirrored, self.layer), \
+               (self.offset, self.height / norm_value, self.rotation, self.dose), \
+               lambda: Text(string=self.string,
+                            height=self.height * norm_value,
+                            font_path=self.font_path,
+                            mirrored=self.mirrored,
+                            layer=self.layer)
+
+    def get_bounds(self) -> numpy.ndarray:
+        # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
+        #  just convert to polygons instead
+        bounds = [[+inf, +inf], [-inf, -inf]]
+        polys = self.to_polygons()
+        for poly in polys:
+            poly_bounds = poly.get_bounds()
+            bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
+            bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
+
+        return bounds
+
+
+def get_char_as_polygons(font_path: str,
+                         char: str,
+                         resolution: float=48*64,
+                         ) -> Tuple[List[List[List[float]]], float]:
+    from freetype import Face
+    from matplotlib.path import Path
+
+    """
+    Get a list of polygons representing a single character.
+
+    The output is normalized so that the font size is 1 unit.
+
+    :param font_path: File path specifying a font loadable by freetype
+    :param char: Character to convert to polygons
+    :param resolution: Internal resolution setting (used for freetype
+            Face.set_font_size(resolution)). Modify at your own peril!
+    :return: List of polygons [[[x0, y0], [x1, y1], ...], ...] and 'advance' distance (distance
+            from the start of this glyph to the start of the next one)
+    """
+    if len(char) != 1:
+        raise Exception('get_char_as_polygons called with non-char')
+
+    face = Face(font_path)
+    face.set_char_size(resolution)
+    face.load_char(char)
+    slot = face.glyph
+    outline = slot.outline
+
+    start = 0
+    all_verts, all_codes = [], []
+    for end in outline.contours:
+        points = outline.points[start:end + 1]
+        points.append(points[0])
+
+        tags = outline.tags[start:end + 1]
+        tags.append(tags[0])
+
+        segments = []
+        for j, point in enumerate(points):
+            # If we already have a segment, add this point to it
+            if j > 0:
+                segments[-1].append(point)
+
+            # If not bezier control point, start next segment
+            if get_bit(tags[j], 0) and j < (len(points) - 1):
+                segments.append([point])
+
+        verts = [points[0]]
+        codes = [Path.MOVETO]
+        for segment in segments:
+            if len(segment) == 2:
+                verts.extend(segment[1:])
+                codes.extend([Path.LINETO])
+            elif len(segment) == 3:
+                verts.extend(segment[1:])
+                codes.extend([Path.CURVE3, Path.CURVE3])
+            else:
+                verts.append(segment[1])
+                codes.append(Path.CURVE3)
+                for i in range(1, len(segment) - 2):
+                    a, b = segment[i], segment[i + 1]
+                    c = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
+                    verts.extend([c, b])
+                    codes.extend([Path.CURVE3, Path.CURVE3])
+                verts.append(segment[-1])
+                codes.append(Path.CURVE3)
+        all_verts.extend(verts)
+        all_codes.extend(codes)
+        start = end + 1
+
+    all_verts = numpy.array(all_verts) / resolution
+
+    advance = slot.advance.x / resolution
+
+    if len(all_verts) == 0:
+        polygons = []
+    else:
+        path = Path(all_verts, all_codes)
+        path.should_simplify = False
+        polygons = path.to_polygons()
+
+    return polygons, advance
diff --git a/masque/subpattern.py b/masque/subpattern.py
new file mode 100644
index 0000000..2ff0033
--- /dev/null
+++ b/masque/subpattern.py
@@ -0,0 +1,185 @@
+"""
+ SubPattern provides basic support for nesting Pattern objects within each other, by adding
+  offset, rotation, scaling, and other such properties to the reference.
+"""
+
+from typing import Union, List
+
+import numpy
+from numpy import pi
+
+from .error import PatternError
+from .utils import is_scalar, rotation_matrix_2d, vector2
+
+
+__author__ = 'Jan Petykiewicz'
+
+
+class SubPattern:
+    """
+    SubPattern provides basic support for nesting Pattern objects within each other, by adding
+     offset, rotation, scaling, and associated methods.
+    """
+
+    pattern = None          # type: Pattern
+    _offset = (0.0, 0.0)    # type: numpy.ndarray
+    _rotation = 0.0         # type: float
+    _dose = 1.0             # type: float
+    _scale = 1.0            # type: float
+    _mirrored = None        # type: List[bool]
+
+    def __init__(self,
+                 pattern: 'Pattern',
+                 offset: vector2=(0.0, 0.0),
+                 rotation: float=0.0,
+                 mirrored: List[bool]=None,
+                 dose: float=1.0,
+                 scale: float=1.0):
+        self.pattern = pattern
+        self.offset = offset
+        self.rotation = rotation
+        self.dose = dose
+        self.scale = scale
+        if mirrored is None:
+            mirrored = [False, False]
+        self.mirrored = mirrored
+
+    # offset property
+    @property
+    def offset(self) -> numpy.ndarray:
+        return self._offset
+
+    @offset.setter
+    def offset(self, val: vector2):
+        if not isinstance(val, numpy.ndarray):
+            val = numpy.array(val, dtype=float)
+
+        if val.size != 2:
+            raise PatternError('Offset must be convertible to size-2 ndarray')
+        self._offset = val.flatten()
+
+    # dose property
+    @property
+    def dose(self) -> float:
+        return self._dose
+
+    @dose.setter
+    def dose(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Dose must be a scalar')
+        if not val >= 0:
+            raise PatternError('Dose must be non-negative')
+        self._dose = val
+
+    # scale property
+    @property
+    def scale(self) -> float:
+        return self._scale
+
+    @scale.setter
+    def scale(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Scale must be a scalar')
+        if not val > 0:
+            raise PatternError('Scale must be positive')
+        self._scale = val
+
+    # Rotation property [ccw]
+    @property
+    def rotation(self) -> float:
+        return self._rotation
+
+    @rotation.setter
+    def rotation(self, val: float):
+        if not is_scalar(val):
+            raise PatternError('Rotation must be a scalar')
+        self._rotation = val % (2 * pi)
+
+    # Mirrored property
+    @property
+    def mirrored(self) -> List[bool]:
+        return self._mirrored
+
+    @mirrored.setter
+    def mirrored(self, val: List[bool]):
+        if is_scalar(val):
+            raise PatternError('Mirrored must be a 2-element list of booleans')
+        self._mirrored = val
+
+    def as_pattern(self) -> 'Pattern':
+        """
+        Returns a copy of self.pattern which has been scaled, rotated, etc. according to this
+         SubPattern's properties.
+        :return: Copy of self.pattern that has been altered to reflect the SubPattern's properties.
+        """
+        pattern = self.pattern.deepcopy()
+        pattern.scale_by(self.scale)
+        [pattern.mirror(ax) for ax, do in enumerate(self.mirrored) if do]
+        pattern.rotate_around((0.0, 0.0), self.rotation)
+        pattern.translate_elements(self.offset)
+        pattern.scale_element_doses(self.dose)
+        return pattern
+
+    def translate(self, offset: vector2) -> 'SubPattern':
+        """
+        Translate by the given offset
+
+        :param offset: Translate by this offset
+        :return: self
+        """
+        self.offset += offset
+        return self
+
+    def rotate_around(self, pivot: vector2, rotation: float) -> 'SubPattern':
+        """
+        Rotate around a point
+
+        :param pivot: Point to rotate around
+        :param rotation: Angle to rotate by (counterclockwise, radians)
+        :return: self
+        """
+        pivot = numpy.array(pivot, dtype=float)
+        self.translate(-pivot)
+        self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
+        self.rotate(rotation)
+        self.translate(+pivot)
+        return self
+
+    def rotate(self, rotation: float) -> 'SubPattern':
+        """
+        Rotate around (0, 0)
+
+        :param rotation: Angle to rotate by (counterclockwise, radians)
+        :return: self
+        """
+        self.rotation += rotation
+        return self
+
+    def mirror(self, axis: int) -> 'SubPattern':
+        """
+        Mirror the subpattern across an axis.
+
+        :param axis: Axis to mirror across.
+        :return: self
+        """
+        self.mirrored[axis] = not self.mirrored[axis]
+        return self
+
+    def get_bounds(self) -> numpy.ndarray or None:
+        """
+        Return a numpy.ndarray containing [[x_min, y_min], [x_max, y_max]], corresponding to the
+         extent of the SubPattern in each dimension.
+        Returns None if the contained Pattern is empty.
+
+        :return: [[x_min, y_min], [x_max, y_max]] or None
+        """
+        return self.as_pattern().get_bounds()
+
+    def scale_by(self, c: float) -> 'SubPattern':
+        """
+        Scale the subpattern by a factor
+
+        :param c: scaling factor
+        """
+        self.scale *= c
+        return self
diff --git a/masque/utils.py b/masque/utils.py
new file mode 100644
index 0000000..5d99835
--- /dev/null
+++ b/masque/utils.py
@@ -0,0 +1,57 @@
+"""
+Various helper functions
+"""
+
+from typing import Any, Union, Tuple
+
+import numpy
+
+# Type definitions
+vector2 = Union[numpy.ndarray, Tuple[float, float]]
+
+
+def is_scalar(var: Any) -> bool:
+    """
+    Alias for 'not hasattr(var, "__len__")'
+
+    :param var: Checks if var has a length.
+    """
+    return not hasattr(var, "__len__")
+
+
+def get_bit(bit_string: Any, bit_id: int) -> bool:
+    """
+    Returns true iff bit number 'bit_id' from the right of 'bit_string' is 1
+
+    :param bit_string: Bit string to test
+    :param bit_id: Bit number, 0-indexed from the right (lsb)
+    :return: value of the requested bit (bool)
+    """
+    return bit_string & (1 << bit_id) != 0
+
+
+def set_bit(bit_string: Any, bit_id: int, value: bool) -> Any:
+    """
+    Returns 'bit_string' with bit number 'bit_id' set to 'value'.
+
+    :param bit_string: Bit string to alter
+    :param bit_id: Bit number, 0-indexed from right (lsb)
+    :param value: Boolean value to set bit to
+    :return: Altered 'bit_string'
+    """
+    mask = (1 << bit_id)
+    bit_string &= ~mask
+    if value:
+        bit_string |= mask
+    return bit_string
+
+
+def rotation_matrix_2d(theta: float) -> numpy.ndarray:
+    """
+    2D rotation matrix for rotating counterclockwise around the origin.
+
+    :param theta: Angle to rotate, in radians
+    :return: rotation matrix
+    """
+    return numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
+                        [numpy.sin(theta), +numpy.cos(theta)]])
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..e3af502
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+from setuptools import setup, find_packages
+import masque
+
+with open('README.md', 'r') as f:
+    long_description = f.read()
+
+setup(name='masque',
+      version=masque.version,
+      description='Lithography mask library',
+      long_description=long_description,
+      long_description_content_type='text/markdown',
+      author='Jan Petykiewicz',
+      author_email='anewusername@gmail.com',
+      url='https://mpxd.net/code/jan/masque',
+      packages=find_packages(),
+      install_requires=[
+            'numpy',
+      ],
+      extras_require={
+          'visualization': ['matplotlib'],
+          'gdsii': ['python-gdsii'],
+          'svg': ['svgwrite'],
+          'text': ['freetype-py', 'matplotlib'],
+      },
+      )
+