From dade74015538a19bcd1a369a0a494eeadee42751 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 19 Apr 2019 23:13:13 -0700 Subject: [PATCH] snapshot 2019-04-19 23:13:13.303952 --- .gitignore | 6 + LICENSE.md | 651 ++++++++++++++++++++++++++++++++++++++ MANIFEST.in | 2 + README.md | 43 +++ examples/ellip_grating.py | 33 ++ masque/__init__.py | 36 +++ masque/error.py | 9 + masque/file/__init__.py | 3 + masque/file/gdsii.py | 558 ++++++++++++++++++++++++++++++++ masque/file/svg.py | 139 ++++++++ masque/file/utils.py | 42 +++ masque/label.py | 129 ++++++++ masque/pattern.py | 539 +++++++++++++++++++++++++++++++ masque/repetition.py | 291 +++++++++++++++++ masque/shapes/__init__.py | 12 + masque/shapes/arc.py | 358 +++++++++++++++++++++ masque/shapes/circle.py | 99 ++++++ masque/shapes/ellipse.py | 166 ++++++++++ masque/shapes/polygon.py | 281 ++++++++++++++++ masque/shapes/shape.py | 386 ++++++++++++++++++++++ masque/shapes/text.py | 224 +++++++++++++ masque/subpattern.py | 202 ++++++++++++ masque/utils.py | 89 ++++++ setup.py | 41 +++ 24 files changed, 4339 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 MANIFEST.in 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..3ef4b5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +__pycache__ +*.idea +build/ +dist/ +*.egg-info/ 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. <>_ + +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. + + + Copyright (C) + + 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 . + +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 +<>. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c28ab72 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include LICENSE.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..150481f --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# 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. + +- [Source repository](https://mpxd.net/code/jan/masque) +- [PyPi](https://pypi.org/project/masque) + + +## 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: +```bash +pip3 install masque +``` + +Alternatively, install from git +```bash +pip3 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..4190cc7 --- /dev/null +++ b/examples/ellip_grating.py @@ -0,0 +1,33 @@ +# 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 = masque.Pattern(name='p2') + pat2.name = 'ellip_grating' + + pat2.subpatterns += [ + masque.SubPattern(pattern=pat, offset=(20e3, 0)), + masque.SubPattern(pattern=pat, offset=(0, 20e3)), + ] + + masque.file.gdsii.write_dose2dtype((pat, pat2, pat2.copy(), pat2.copy()), '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..acf7cbc --- /dev/null +++ b/masque/__init__.py @@ -0,0 +1,36 @@ +""" + 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 .repetition import GridRepetition +from .pattern import Pattern + + +__author__ = 'Jan Petykiewicz' + +version = '0.5' 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..b9cfd05 --- /dev/null +++ b/masque/file/gdsii.py @@ -0,0 +1,558 @@ +""" +GDSII file format readers and writers +""" +# python-gdsii +import gdsii.library +import gdsii.structure +import gdsii.elements + +from typing import List, Any, Dict, Tuple +import re +import numpy +import base64 +import struct +import logging + +from .utils import mangle_name, make_dose_table +from .. import Pattern, SubPattern, GridRepetition, PatternError, Label, Shape +from ..shapes import Polygon +from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar +from ..utils import remove_colinear_vertices + + +__author__ = 'Jan Petykiewicz' + + +logger = logging.getLogger(__name__) + + +def write(patterns: Pattern or List[Pattern], + filename: str, + meters_per_unit: float, + logical_units_per_unit: float = 1, + library_name: str = 'masque-gdsii-write'): + """ + 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. + :param library_name: Library name written into the GDSII file. + Default 'masque-gdsii-write'. + """ + # Create library + lib = gdsii.library.Library(version=600, + name=library_name.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()) + + _disambiguate_pattern_names(patterns_by_id.values()) + + # Now create a structure for each pattern, and add in any Boundary and SREF elements + for pat in patterns_by_id.values(): + structure = gdsii.structure.Structure(name=pat.name) + lib.append(structure) + + # Add a Boundary element for each shape + structure += _shapes_to_boundaries(pat.shapes) + + structure += _labels_to_texts(pat.labels) + + # Add an SREF / AREF for each subpattern entry + structure += _subpatterns_to_refs(pat.subpatterns) + + with open(filename, mode='wb') as stream: + lib.save(stream) + + +def write_dose2dtype(patterns: Pattern or List[Pattern], + filename: str, + meters_per_unit: float, + *args, + **kwargs, + ) -> 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 args: passed to masque.file.gdsii.write(). + :param kwargs: passed to masque.file.gdsii.write(). + :returns: A list of doses, providing a mapping between datatype (int, list index) + and dose (float, list entry). + """ + patterns, dose_vals = dose2dtype(patterns) + write(patterns, filename, meters_per_unit, *args, **kwargs) + return dose_vals + + +def dose2dtype(patterns: Pattern or List[Pattern], + ) -> Tuple[List[Pattern], List[float]]: + """ + For each shape in each pattern, set shape.layer to the tuple + (base_layer, datatype), where: + layer is chosen to be equal to the original 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 input Pattern(s). + + :param patterns: A Pattern or list of patterns to write to file. Modified by this function. + :returns: (patterns, dose_list) + patterns: modified input patterns + dose_list: A list of doses, providing a mapping between datatype (int, list index) + and dose (float, list entry). + """ + 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))) + + # Create a new pattern for each non-1-dose entry in the dose table + # and update the shapes to reflect their new dose + new_pats = {} # (id, dose) -> new_pattern mapping + for pat_id, pat_dose in sd_table: + if pat_dose == 1: + new_pats[(pat_id, pat_dose)] = patterns_by_id[pat_id] + continue + + pat = patterns_by_id[pat_id].deepcopy() + + 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)) + + for shape in pat.shapes: + data_type = dose_vals_list.index(shape.dose * pat_dose) + if is_scalar(shape.layer): + layer = (shape.layer, data_type) + else: + layer = (shape.layer[0], data_type) + + new_pats[(pat_id, pat_dose)] = pat + + # Go back through all the dose-specific patterns and fix up their subpattern entries + for (pat_id, pat_dose), pat in new_pats.items(): + for subpat in pat.subpatterns: + dose_mult = subpat.dose * pat_dose + subpat.pattern = new_pats[(id(subpat.pattern), dose_mult)] + + return patterns, list(dose_vals) + + +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, + } + + 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) + + if isinstance(element, gdsii.elements.Path): + if element.path_type == 0: + extension = 0.0 + elif element.path_type in (1, 4): + raise PatternError('Round-ended and custom paths (types 1 and 4) are not implemented yet') + elif element.path_type == 2: + extension = element.width / 2 + else: + raise PatternError('Unrecognized path type: {}'.format(element.path_type)) + + if element.width == 0: + continue + + #TODO add extension + v = remove_colinear_vertices(numpy.array(element.xy, dtype=float), closed_path=False) + dv = numpy.diff(v, axis=0) + dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] + v[0] -= dvdir[0] * extension + v[-1] += dvdir[-1] * extension + perp = dvdir[:, ::-1] * [1, -1] * element.width / 2 + + o0 = [v[0] + perp[0]] + o1 = [v[0] - perp[0]] + + for i in range(1, dv.shape[0]): + towards_perp = numpy.dot(perp[i - 1], dv[i]) > 0 # bends towards previous perp + #straight = numpy.dot(perp[i - 1], dv[i]) == 0 # TODO maybe run without cleaning? + acute = numpy.dot(dv[i - 1], dv[i]) < 0 + + A = numpy.column_stack((dv[i - 1], -dv[i])) + ab = numpy.linalg.solve(A, v[i] + perp[i] - v[i - 1] - perp[i - 1]) + cd = numpy.linalg.solve(A, v[i] - perp[i] - v[i - 1] + perp[i - 1]) + perpside_intersection = v[i - 1] + ab[0] * dv[i - 1] + perp[i - 1] + otherside_intersection = v[i - 1] + cd[0] * dv[i - 1] - perp[i - 1] + if towards_perp: + o0.append(perpside_intersection) + if acute: + # Opposite is 180 > angle > 270 + o1.append(otherside_intersection) + else: + # Opposite is >270 + pt0 = v[i] - perp[i - 1] + dvdir[i - 1] * element.width / 2 + pt1 = v[i] - perp[i] - dvdir[i] * element.width / 2 + o1 += [pt0, pt1] + else: + # > 180, opposite is <180 + o1.append(otherside_intersection) + print('oi', otherside_intersection) + if acute: + # > 270, opposite is <90 + pt0 = v[i] + perp[i - 1] + dvdir[i - 1] * element.width / 2 + pt1 = v[i] + perp[i] - dvdir[i] * element.width / 2 + o0 += [pt0, pt1] + else: + # 180 > angle >270 + o0.append(perpside_intersection) + o0.append(v[-1] + perp[-1]) + o1.append(v[-1] - perp[-1]) + verts = numpy.vstack((o0, o1[::-1])) + + if use_dtype_as_dose: + shape = Polygon(vertices=verts, + dose=element.data_type, + layer=element.layer) + else: + shape = Polygon(vertices=verts, + layer=(element.layer, element.data_type)) + if clean_vertices: + try: + shape.clean_vertices() + except PatternError as err: + print('error cleaning! {}'.format(err)) + 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(_sref_to_subpat(element)) + + elif isinstance(element, gdsii.elements.ARef): + pat.subpatterns.append(_aref_to_gridrep(element)) + + 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 + + +def _sref_to_subpat(element: gdsii.elements.SRef) -> SubPattern: + # Helper function to create a SubPattern from an SREF. 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=element.xy) + 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 + + +def _aref_to_gridrep(element: gdsii.elements.ARef) -> GridRepetition: + # Helper function to create a GridRepetition from an AREF. Sets gridrep.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.i + + rotation = 0 + offset = numpy.array(element.xy[0]) + scale = 1 + mirror_signs = numpy.ones(2) + + if element.strans is not None: + if element.mag is not None: + scale = element.mag + # Bit 13 means absolute scale + if get_bit(element.strans, 15 - 13): + raise PatternError('Absolute scale is not implemented yet!') + if element.angle is not None: + rotation = element.angle * numpy.pi / 180 + # Bit 14 means absolute rotation + if get_bit(element.strans, 15 - 14): + raise PatternError('Absolute rotation is not implemented yet!') + # Bit 0 means mirror x-axis + if get_bit(element.strans, 15 - 0): + mirror_signs[0] = -1 + + counts = [element.cols, element.rows] + vec_a0 = element.xy[1] - offset + vec_b0 = element.xy[2] - offset + + a_vector = numpy.dot(rotation_matrix_2d(-rotation), vec_a0 / scale / counts[0]) * mirror_signs + b_vector = numpy.dot(rotation_matrix_2d(-rotation), vec_b0 / scale / counts[1]) * mirror_signs + + + gridrep = GridRepetition(pattern=None, + a_vector=a_vector, + b_vector=b_vector, + a_count=counts[0], + b_count=counts[1], + offset=offset, + rotation=rotation, + scale=scale, + mirrored=(mirror_signs == -1)) + gridrep.ref_name = element.struct_name + + return gridrep + + +def _subpatterns_to_refs(subpatterns: List[SubPattern or GridRepetition] + ) -> List[gdsii.elements.ARef or gdsii.elements.SRef]: + # strans must be set for angle and mag to take effect + refs = [] + for subpat in subpatterns: + encoded_name = subpat.pattern.name + + if isinstance(subpat, GridRepetition): + mirror_signs = (-1) ** numpy.array(subpat.mirrored) + xy = numpy.array(subpat.offset) + [ + [0, 0], + numpy.dot(rotation_matrix_2d(subpat.rotation), subpat.a_vector * mirror_signs) * subpat.scale * subpat.a_count, + numpy.dot(rotation_matrix_2d(subpat.rotation), subpat.b_vector * mirror_signs) * subpat.scale * subpat.b_count, + ] + ref = gdsii.elements.ARef(struct_name=encoded_name, + xy=numpy.round(xy).astype(int), + cols=numpy.round(subpat.a_count).astype(int), + rows=numpy.round(subpat.b_count).astype(int)) + else: + ref = gdsii.elements.SRef(struct_name=encoded_name, + xy=numpy.round([subpat.offset]).astype(int)) + + ref.strans = 0 + ref.angle = subpat.rotation * 180 / numpy.pi + mirror_x, mirror_y = subpat.mirrored + if mirror_x and mirror_y: + ref.angle += 180 + elif mirror_x: + ref.strans = set_bit(ref.strans, 15 - 0, True) + elif mirror_y: + ref.angle += 180 + ref.strans = set_bit(ref.strans, 15 - 0, True) + ref.angle %= 360 + ref.mag = subpat.scale + + refs.append(ref) + return refs + + +def _shapes_to_boundaries(shapes: List[Shape] + ) -> List[gdsii.elements.Boundary]: + # Add a Boundary element for each shape + boundaries = [] + for shape in 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, :])) + boundaries.append(gdsii.elements.Boundary(layer=layer, + data_type=data_type, + xy=xy_closed)) + return boundaries + + +def _labels_to_texts(labels: List[Label]) -> List[gdsii.elements.Text]: + texts = [] + for label in labels: + layer, text_type = _mlayer2gds(label.layer) + xy = numpy.round([label.offset]).astype(int) + texts.append(gdsii.elements.Text(layer=layer, + text_type=text_type, + xy=xy, + string=label.string.encode('ASCII'))) + return texts + + +def _disambiguate_pattern_names(patterns): + used_names = [] + for pat in patterns: + sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', pat.name) + + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning('Empty pattern name saved as "{}"'.format(suffixed_name)) + elif suffixed_name != sanitized_name: + logger.warning('Pattern name "{}" appears multiple times; renaming to "{}"'.format(pat.name, suffixed_name)) + + encoded_name = suffixed_name.encode('ASCII') + if len(encoded_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError('Zero-length name after sanitize+encode, originally "{}"'.format(pat.name)) + if len(encoded_name) > 32: + raise PatternError('Pattern name "{}" length > 32 after encode, originally "{}"'.format(encoded_name, pat.name)) + + pat.name = encoded_name + used_names.append(suffixed_name) 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 (, inside ), polygons as paths (), and subpatterns + as 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 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..26d7293 --- /dev/null +++ b/masque/pattern.py @@ -0,0 +1,539 @@ +""" + 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 .repetition import GridRepetition +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 or GridRepetition] + 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..9742733 --- /dev/null +++ b/masque/repetition.py @@ -0,0 +1,291 @@ +""" + Repetitions provides support for efficiently nesting multiple identical + instances of a Pattern in the same parent Pattern. +""" + +from typing import Union, List +import copy + +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: + """ + GridRepetition provides support for efficiently embedding multiple copies of a Pattern + into another Pattern at regularly-spaced offsets. + """ + + 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: numpy.ndarray + _b_vector = None # type: numpy.ndarray + a_count = None # type: int + b_count = 1 # type: int + + def __init__(self, + pattern: 'Pattern', + a_vector: numpy.ndarray, + a_count: int, + b_vector: numpy.ndarray = 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().astype(float) + + # 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 + + # a_vector property + @property + def a_vector(self) -> numpy.ndarray: + return self._a_vector + + @a_vector.setter + def a_vector(self, val: vector2): + if not isinstance(val, numpy.ndarray): + val = numpy.array(val, dtype=float) + + if val.size != 2: + raise PatternError('a_vector must be convertible to size-2 ndarray') + self._a_vector = val.flatten() + + # b_vector property + @property + def b_vector(self) -> numpy.ndarray: + return self._b_vector + + @b_vector.setter + def b_vector(self, val: vector2): + if not isinstance(val, numpy.ndarray): + val = numpy.array(val, dtype=float) + + if val.size != 2: + raise PatternError('b_vector must be convertible to size-2 ndarray') + self._b_vector = val.flatten() + + + 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 + + patterns = [] + + for a in range(self.a_count): + for b in range(self.b_count): + offset = a * self.a_vector + b * self.b_vector + newPat = self.pattern.deepcopy() + newPat.translate_elements(offset) + patterns.append(newPat) + + combined = patterns[0] + for p in patterns[1:]: + combined.append(p) + + combined.scale_by(self.scale) + [combined.mirror(ax) for ax, do in enumerate(self.mirrored) if do] + combined.rotate_around((0.0, 0.0), self.rotation) + combined.translate_elements(self.offset) + combined.scale_element_doses(self.dose) + + return combined + + 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 + + def copy(self) -> 'GridRepetition': + """ + Return a shallow copy of the repetition. + + :return: copy.copy(self) + """ + return copy.copy(self) + + def deepcopy(self) -> 'SubPattern': + """ + Return a deep copy of the repetition. + + :return: copy.copy(self) + """ + return copy.deepcopy(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..a0b214b --- /dev/null +++ b/masque/shapes/polygon.py @@ -0,0 +1,281 @@ +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 +from ..utils import remove_colinear_vertices, remove_duplicate_vertices + +__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 ly: 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 + ''' + self.vertices = remove_duplicate_vertices(self.vertices, closed_path=True) + return self + + def remove_colinear_vertices(self) -> 'Polygon': + ''' + Removes consecutive co-linear vertices. + + :returns: self + ''' + self.vertices = remove_colinear_vertices(self.vertices, closed_path=True) + return self diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py new file mode 100644 index 0000000..981e2ce --- /dev/null +++ b/masque/shapes/shape.py @@ -0,0 +1,386 @@ +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 + + # Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape + gxi_range = numpy.digitize([v[0], v_next[0]], grid_x) + gxi_min = numpy.min(gxi_range - 1).clip(0, len(grid_x) - 1) + gxi_max = numpy.max(gxi_range).clip(0, len(grid_x)) + + err_xmin = (min(v[0], v_next[0]) - grid_x[gxi_min]) / (grid_x[gxi_min + 1] - grid_x[gxi_min]) + err_xmax = (max(v[0], v_next[0]) - grid_x[gxi_max - 1]) / (grid_x[gxi_max] - grid_x[gxi_max - 1]) + + if err_xmin >= 0.5: + gxi_min += 1 + if err_xmax >= 0.5: + gxi_max += 1 + + + if abs(dv[0]) < 1e-20: + # Vertical line, don't calculate slope + xi = [gxi_min, gxi_max - 1] + ys = numpy.array([v[1], v_next[1]]) + yi = numpy.digitize(ys, grid_y).clip(1, len(grid_y) - 1) + err_y = (ys - grid_y[yi]) / (grid_y[yi] - grid_y[yi - 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 + return inds + + # Find the y indices on all x gridlines + xs = grid_x[gxi_min:gxi_max] + inds = get_grid_inds(xs) + + # Find y-intersections for x-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..0415894 --- /dev/null +++ b/masque/subpattern.py @@ -0,0 +1,202 @@ +""" + 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 copy + +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().astype(float) + + # 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 + + def copy(self) -> 'SubPattern': + """ + Return a shallow copy of the subpattern. + + :return: copy.copy(self) + """ + return copy.copy(self) + + def deepcopy(self) -> 'SubPattern': + """ + Return a deep copy of the subpattern. + + :return: copy.copy(self) + """ + return copy.deepcopy(self) diff --git a/masque/utils.py b/masque/utils.py new file mode 100644 index 0000000..b1faa5f --- /dev/null +++ b/masque/utils.py @@ -0,0 +1,89 @@ +""" +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)]]) + + +def remove_duplicate_vertices(vertices: numpy.ndarray, closed_path: bool = True) -> numpy.ndarray: + duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1) + if not closed_path: + duplicates[0] = False + return vertices[~duplicates] + + +def remove_colinear_vertices(vertices: numpy.ndarray, closed_path: bool = True) -> numpy.ndarray: + ''' + Given a list of vertices, remove any superflous vertices (i.e. + those which lie along the line formed by their neighbors) + + :param vertices: Nx2 ndarray of vertices + :param closed_path: If True, the vertices are assumed to represent an implicitly + closed path. If False, the path is assumed to be open. Default True. + :return: + ''' + # Check for dx0/dy0 == dx1/dy1 + + dv = numpy.roll(vertices, 1, axis=0) - vertices #[y0 - yn1, y1-y0, ...] + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx1*dy0, dx1*dy0], ...] + + dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] + err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 + + slopes_equal = (dxdy_diff / err_mult) < 1e-15 + if not closed_path: + slopes_equal[[0, -1]] = False + + return vertices[~slopes_equal] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6cda402 --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +#!/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'], + }, + classifiers=[ + 'Programming Language :: Python :: 3', + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'Intended Audience :: Manufacturing', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: GNU Affero General Public License v3', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Microsoft :: Windows', + 'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)', + 'Topic :: Scientific/Engineering :: Visualization', + ], + ) +