3 # ##### BEGIN GPL LICENSE BLOCK #####
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software Foundation,
17 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110- 1301, USA.
19 # ##### END GPL LICENSE BLOCK #####
23 # ----------------------------------------------------------
24 # Author: Stephen Leger (s-leger)
25 # Cutter / CutAble shared by roof, slab, and floor
26 # ----------------------------------------------------------
27 from mathutils
import Vector
, Matrix
28 from mathutils
.geometry
import interpolate_bezier
29 from math
import cos
, sin
, pi
, atan2
31 from random
import uniform
32 from bpy
.props
import (
33 FloatProperty
, IntProperty
, BoolProperty
,
34 StringProperty
, EnumProperty
36 from .archipack_2d
import Line
39 class CutterSegment(Line
):
41 def __init__(self
, p
, v
, type='DEFAULT'):
42 Line
.__init
__(self
, p
, v
)
48 return CutterSegment(self
.p
.copy(), self
.v
.copy(), self
.type)
50 def straight(self
, length
, t
=1):
53 s
.v
= self
.v
.normalized() * length
56 def set_offset(self
, offset
, last
=None):
58 Offset line and compute intersection point
61 self
.line
= self
.make_offset(offset
, last
)
63 def offset(self
, offset
):
65 s
.p
+= self
.sized_normal(0, offset
).v
76 class CutterGenerator():
78 def __init__(self
, d
):
80 self
.operation
= d
.operation
83 def add_part(self
, part
):
85 if len(self
.segs
) < 1:
92 v
= part
.length
* Vector((cos(part
.a0
), sin(part
.a0
)))
93 s
= CutterSegment(Vector((0, 0)), v
, part
.type)
95 s
= s
.straight(part
.length
).rotate(part
.a0
)
100 def set_offset(self
):
102 for i
, seg
in enumerate(self
.segs
):
103 seg
.set_offset(self
.parts
[i
].offset
, last
)
107 # Make last segment implicit closing one
113 if len(self
.segs
) > 1:
114 s0
.line
= s0
.make_offset(self
.parts
[-1].offset
, self
.segs
[-2].line
)
117 s1
.line
= s1
.make_offset(self
.parts
[0].offset
, s0
.line
)
120 def locate_manipulators(self
):
121 if self
.operation
== 'DIFFERENCE':
125 for i
, f
in enumerate(self
.segs
):
127 manipulators
= self
.parts
[i
].manipulators
130 # angle from last to current segment
133 if i
< len(self
.segs
) - 1:
134 manipulators
[0].type_key
= 'ANGLE'
136 manipulators
[0].type_key
= 'DUMB_ANGLE'
138 v0
= self
.segs
[i
- 1].straight(-side
, 1).v
.to_3d()
139 v1
= f
.straight(side
, 0).v
.to_3d()
140 manipulators
[0].set_pts([p0
, v0
, v1
])
143 manipulators
[1].type_key
= 'SIZE'
144 manipulators
[1].prop1_name
= "length"
145 manipulators
[1].set_pts([p0
, p1
, (side
, 0, 0)])
147 # snap manipulator, don't change index !
148 manipulators
[2].set_pts([p0
, p1
, (side
, 0, 0)])
150 manipulators
[3].set_pts([p0
, p1
, (side
, 0, 0)])
153 manipulators
[4].set_pts([
155 p0
+ f
.sized_normal(0, max(0.0001, self
.parts
[i
].offset
)).v
.to_3d(),
159 def change_coordsys(self
, fromTM
, toTM
):
161 move shape fromTM into toTM coordsys
163 dp
= (toTM
.inverted() @ fromTM
.translation
).to_2d()
164 da
= toTM
.row
[1].to_2d().angle_signed(fromTM
.row
[1].to_2d())
172 tp
= (rM
@ s
.p0
) - s
.p0
+ dp
176 def get_index(self
, index
):
177 n_segs
= len(self
.segs
)
182 def next_seg(self
, index
):
183 idx
= self
.get_index(index
+ 1)
184 return self
.segs
[idx
]
186 def last_seg(self
, index
):
187 return self
.segs
[index
- 1]
189 def get_verts(self
, verts
, edges
):
191 n_segs
= len(self
.segs
) - 1
194 verts
.append(s
.line
.p0
.to_3d())
196 for i
in range(n_segs
):
197 edges
.append([i
, i
+ 1])
200 class CutAblePolygon():
202 Simple boolean operations
203 Cutable generator / polygon
204 Object MUST have properties
209 def as_lines(self
, step_angle
=0.104):
211 Convert curved segments to straight lines
215 if "Curved" in type(s
).__name
__:
216 dt
, steps
= s
.steps_by_angle(step_angle
)
217 segs
.extend(s
.as_lines(steps
))
222 def inside(self
, pt
, segs
=None):
224 Point inside poly (raycast method)
225 support concave polygons
227 make s1 angle different than all othr segs
229 s1
= Line(pt
, Vector((min(10000, 100 * self
.xsize
), uniform(-0.5, 0.5))))
234 res
, p
, t
, u
= s
.intersect_ext(s1
)
237 return counter
% 2 == 1
239 def get_index(self
, index
):
240 n_segs
= len(self
.segs
)
246 n_segs
= len(self
.segs
)
250 for i
in range(n_segs
):
252 if "Curved" in type(s1
).__name
__:
258 elif sign
!= (c
> 0):
263 def get_intersections(self
, border
, cutter
, s_start
, segs
, start_by_hole
):
265 Detect all intersections
266 for boundary: store intersection point, t, idx of segment, idx of cutter
271 s_nsegs
= len(s_segs
)
272 b_nsegs
= len(b_segs
)
275 # find all intersections
276 for idx
in range(s_nsegs
):
277 s_idx
= border
.get_index(s_start
+ idx
)
279 for b_idx
, b
in enumerate(b_segs
):
280 res
, p
, u
, v
= s
.intersect_ext(b
)
282 inter
.append((s_idx
, u
, b_idx
, v
, p
))
284 # print("%s" % (self.side))
285 # print("%s" % (inter))
290 # sort by seg and param t of seg
293 # reorder so we really start from s_start
294 for i
, it
in enumerate(inter
):
299 inter
= inter
[order
:] + inter
[:order
]
301 # print("%s" % (inter))
302 p0
= border
.segs
[s_start
].p0
304 n_inter
= len(inter
) - 1
306 for i
in range(n_inter
):
307 s_end
, u
, b_start
, v
, p
= inter
[i
]
308 s_idx
= border
.get_index(s_start
)
309 s
= s_segs
[s_idx
].copy
310 s
.is_hole
= not start_by_hole
314 # walk through s_segs until intersection
315 while s_idx
!= s_end
and max_iter
> 0:
317 s_idx
= border
.get_index(idx
)
318 s
= s_segs
[s_idx
].copy
319 s
.is_hole
= not start_by_hole
324 s_start
, u
, b_end
, v
, p
= inter
[i
+ 1]
325 b_idx
= cutter
.get_index(b_start
)
326 s
= b_segs
[b_idx
].copy
327 s
.is_hole
= start_by_hole
331 # walk through b_segs until intersection
332 while b_idx
!= b_end
and max_iter
> 0:
334 b_idx
= cutter
.get_index(idx
)
335 s
= b_segs
[b_idx
].copy
336 s
.is_hole
= start_by_hole
341 # add part between last intersection and start point
343 s_idx
= border
.get_index(s_start
)
344 s
= s_segs
[s_idx
].copy
345 s
.is_hole
= not start_by_hole
348 # go until end of segment is near start of first one
349 while (s_segs
[s_idx
].p1
- p0
).length
> 0.0001 and max_iter
> 0:
351 s_idx
= border
.get_index(idx
)
352 s
= s_segs
[s_idx
].copy
353 s
.is_hole
= not start_by_hole
357 if len(segs
) > s_nsegs
+ b_nsegs
+ 1:
358 # print("slice failed found:%s of:%s" % (len(segs), s_nsegs + b_nsegs))
361 for i
, s
in enumerate(segs
):
362 s
.p0
= segs
[i
- 1].p1
366 def slice(self
, cutter
):
368 Simple 2d Boolean between boundary and roof part
369 Doesn't handle slicing roof into multiple parts
372 1 pitch has point in boundary -> start from this point
373 2 boundary has point in pitch -> start from this point
374 3 no points inside -> find first crossing segment
375 4 not points inside and no crossing segments
377 # print("************")
379 # keep inside or cut inside
380 # keep inside must be CCW
381 # cut inside must be CW
382 keep_inside
= (cutter
.operation
== 'INTERSECTION')
393 # find if either a cutter or
395 # (at least one point of any must be inside other one)
397 # find a point of this pitch inside cutter
398 for i
, s
in enumerate(f_segs
):
399 res
= self
.inside(s
.p0
, c_segs
)
402 if res
== keep_inside
:
404 # print("pitch pt %sside f_start:%s %s" % (in_out, start, self.side))
405 slice_res
= self
.get_intersections(self
, cutter
, start
, store
, True)
408 # seek for point of cutter inside pitch
409 for i
, s
in enumerate(c_segs
):
410 res
= self
.inside(s
.p0
)
413 # no pitch point found inside cutter
414 if start
< 0 and res
== keep_inside
:
416 # print("cutter pt %sside c_start:%s %s" % (in_out, start, self.side))
417 # swap cutter / pitch so we start from cutter
418 slice_res
= self
.get_intersections(cutter
, self
, start
, store
, False)
421 # no points found at all
423 # print("no pt inside")
424 return not keep_inside
427 # print("slice fails")
428 # found more segments than input
429 # cutter made more than one loop
434 # print("not touching, add as hole")
436 self
.segs
= cutter
.segs
438 self
.holes
.append(cutter
)
448 class CutAbleGenerator():
450 def bissect(self
, bm
,
454 use_snap_center
=False,
459 geom
.extend(bm
.edges
[:])
460 geom
.extend(bm
.faces
[:])
462 bmesh
.ops
.bisect_plane(bm
,
467 use_snap_center
=False,
468 clear_outer
=clear_outer
,
469 clear_inner
=clear_inner
472 def cut_holes(self
, bm
, cutable
, offset
={'DEFAULT': 0}):
473 o_keys
= offset
.keys()
474 has_offset
= len(o_keys
) > 1 or offset
['DEFAULT'] != 0
476 for hole
in cutable
.holes
:
485 of
= offset
['DEFAULT']
486 n
= s
.sized_normal(0, 1).v
488 self
.bissect(bm
, p0
.to_3d(), n
.to_3d(), clear_outer
=False)
490 # compute boundary with offset
498 of
= offset
['DEFAULT']
499 new_s
= s
.make_offset(of
, new_s
)
501 # last / first intersection
503 res
, p0
, t
= segs
[0].intersect(segs
[-1])
511 n
= s
.sized_normal(0, 1).v
512 self
.bissect(bm
, s
.p0
.to_3d(), n
.to_3d(), clear_outer
=False)
516 # when hole segs are found clear parts inside hole
517 f_geom
= [f
for f
in bm
.faces
519 f
.calc_center_median().to_2d(),
522 bmesh
.ops
.delete(bm
, geom
=f_geom
, context
='FACES')
524 def cut_boundary(self
, bm
, cutable
, offset
={'DEFAULT': 0}):
525 o_keys
= offset
.keys()
526 has_offset
= len(o_keys
) > 1 or offset
['DEFAULT'] != 0
529 for s
in cutable
.segs
:
534 of
= offset
['DEFAULT']
535 n
= s
.sized_normal(0, 1).v
537 self
.bissect(bm
, p0
.to_3d(), n
.to_3d(), clear_outer
=cutable
.convex
)
539 for s
in cutable
.segs
:
541 n
= s
.sized_normal(0, 1).v
542 self
.bissect(bm
, s
.p0
.to_3d(), n
.to_3d(), clear_outer
=cutable
.convex
)
544 if not cutable
.convex
:
545 f_geom
= [f
for f
in bm
.faces
546 if not cutable
.inside(f
.calc_center_median().to_2d())]
548 bmesh
.ops
.delete(bm
, geom
=f_geom
, context
='FACES')
551 def update_hole(self
, context
):
552 # update parent's only when manipulated
553 self
.update(context
, update_parent
=True)
556 class ArchipackCutterPart():
558 Cutter segment PropertyGroup
560 Childs MUST implements
565 length
: FloatProperty(
577 subtype
='ANGLE', unit
='ROTATION',
580 offset
: FloatProperty(
587 def find_in_selection(self
, context
):
588 raise NotImplementedError
590 def draw(self
, layout
, context
, index
):
592 box
.prop(self
, "type", text
=str(index
+ 1))
593 box
.prop(self
, "length")
594 # box.prop(self, "offset")
597 def update(self
, context
, update_parent
=False):
598 props
= self
.find_in_selection(context
)
599 if props
is not None:
600 props
.update(context
, update_parent
=update_parent
)
603 def update_operation(self
, context
):
604 self
.reverse(context
, make_ccw
=(self
.operation
== 'INTERSECTION'))
607 def update_path(self
, context
):
608 self
.update_path(context
)
611 def update(self
, context
):
615 def update_manipulators(self
, context
):
616 self
.update(context
, manipulable_refresh
=True)
619 class ArchipackCutter():
620 n_parts
: IntProperty(
623 default
=1, update
=update_manipulators
627 description
="Dumb z for manipulator placeholder",
629 options
={'SKIP_SAVE'}
631 user_defined_path
: StringProperty(
635 user_defined_resolution
: IntProperty(
639 default
=12, update
=update_path
641 operation
: EnumProperty(
643 ('DIFFERENCE', 'Difference', 'Cut inside part', 0),
644 ('INTERSECTION', 'Intersection', 'Keep inside part', 1)
646 default
='DIFFERENCE',
647 update
=update_operation
649 auto_update
: BoolProperty(
650 options
={'SKIP_SAVE'},
652 update
=update_manipulators
655 parts_expand
: BoolProperty(
661 def draw(self
, layout
, context
):
664 if self
.parts_expand
:
665 row
.prop(self
, 'parts_expand', icon
="TRIA_DOWN", text
="Parts", emboss
=False)
666 box
.prop(self
, 'n_parts')
667 for i
, part
in enumerate(self
.parts
):
668 part
.draw(layout
, context
, i
)
670 row
.prop(self
, 'parts_expand', icon
="TRIA_RIGHT", text
="Parts", emboss
=False)
672 def update_parts(self
):
673 # print("update_parts")
677 # as last one is end point of last segment or closing one
678 for i
in range(len(self
.parts
), self
.n_parts
+ 1, -1):
679 self
.parts
.remove(i
- 1)
682 for i
in range(len(self
.parts
), self
.n_parts
+ 1):
685 self
.setup_manipulators()
687 def update_parent(self
, context
):
688 raise NotImplementedError
690 def setup_manipulators(self
):
691 for i
in range(self
.n_parts
+ 1):
693 n_manips
= len(p
.manipulators
)
695 s
= p
.manipulators
.add()
699 s
= p
.manipulators
.add()
701 s
.prop1_name
= "length"
703 s
= p
.manipulators
.add()
704 s
.type_key
= 'WALL_SNAP'
705 s
.prop1_name
= str(i
)
708 s
= p
.manipulators
.add()
709 s
.type_key
= 'DUMB_STRING'
710 s
.prop1_name
= str(i
+ 1)
712 s
= p
.manipulators
.add()
714 s
.prop1_name
= "offset"
715 p
.manipulators
[2].prop1_name
= str(i
)
716 p
.manipulators
[3].prop1_name
= str(i
+ 1)
718 def get_generator(self
):
719 g
= CutterGenerator(self
)
720 for i
, part
in enumerate(self
.parts
):
726 def interpolate_bezier(self
, pts
, wM
, p0
, p1
, resolution
):
727 # straight segment, worth testing here
728 # since this can lower points count by a resolution factor
729 # use normalized to handle non linear t
731 pts
.append(wM
@ p0
.co
.to_3d())
733 v
= (p1
.co
- p0
.co
).normalized()
734 d1
= (p0
.handle_right
- p0
.co
).normalized()
735 d2
= (p1
.co
- p1
.handle_left
).normalized()
736 if d1
== v
and d2
== v
:
737 pts
.append(wM
@ p0
.co
.to_3d())
739 seg
= interpolate_bezier(wM
@ p0
.co
,
740 wM
@ p0
.handle_right
,
744 for i
in range(resolution
):
745 pts
.append(seg
[i
].to_3d())
747 def is_cw(self
, pts
):
751 d
+= (p
.x
* p0
.y
- p
.y
* p0
.x
)
755 def ensure_direction(self
):
756 # get segs ensure they are cw or ccw depending on operation
757 # whatever the user do with points
758 g
= self
.get_generator()
759 pts
= [seg
.p0
.to_3d() for seg
in g
.segs
]
760 if self
.is_cw(pts
) != (self
.operation
== 'INTERSECTION'):
762 g
.segs
= [s
.oposite
for s
in reversed(g
.segs
)]
765 def from_spline(self
, context
, wM
, resolution
, spline
):
767 if spline
.type == 'POLY':
768 pts
= [wM
@ p
.co
.to_3d() for p
in spline
.points
]
769 if spline
.use_cyclic_u
:
771 elif spline
.type == 'BEZIER':
772 points
= spline
.bezier_points
773 for i
in range(1, len(points
)):
776 self
.interpolate_bezier(pts
, wM
, p0
, p1
, resolution
)
777 if spline
.use_cyclic_u
:
780 self
.interpolate_bezier(pts
, wM
, p0
, p1
, resolution
)
783 pts
.append(wM
@ points
[-1].co
)
785 if self
.is_cw(pts
) == (self
.operation
== 'INTERSECTION'):
786 pts
= list(reversed(pts
))
788 pt
= wM
.inverted() @ pts
[0]
791 o
= self
.find_in_selection(context
, self
.auto_update
)
792 o
.matrix_world
= wM
@ Matrix
.Translation(pt
)
793 self
.auto_update
= False
794 self
.from_points(pts
)
795 self
.auto_update
= True
796 self
.update_parent(context
, o
)
798 def from_points(self
, pts
):
800 self
.n_parts
= len(pts
) - 2
806 for i
, p1
in enumerate(pts
):
808 da
= atan2(dp
.y
, dp
.x
) - a0
813 if i
>= len(self
.parts
):
814 # print("Too many pts for parts")
817 p
.length
= dp
.to_2d().length
823 def reverse(self
, context
, make_ccw
=False):
825 o
= self
.find_in_selection(context
, self
.auto_update
)
827 g
= self
.get_generator()
829 pts
= [seg
.p0
.to_3d() for seg
in g
.segs
]
831 if self
.is_cw(pts
) != make_ccw
:
834 types
= [p
.type for p
in self
.parts
]
838 pts
= list(reversed(pts
))
839 self
.auto_update
= False
841 self
.from_points(pts
)
843 for i
, type in enumerate(reversed(types
)):
844 self
.parts
[i
].type = type
845 self
.auto_update
= True
846 self
.update_parent(context
, o
)
848 def update_path(self
, context
):
850 user_def_path
= context
.scene
.objects
.get(self
.user_defined_path
.strip())
851 if user_def_path
is not None and user_def_path
.type == 'CURVE':
852 self
.from_spline(context
,
853 user_def_path
.matrix_world
,
854 self
.user_defined_resolution
,
855 user_def_path
.data
.splines
[0])
857 def make_surface(self
, o
, verts
, edges
):
861 bm
.verts
.ensure_lookup_table()
863 bm
.edges
.new((bm
.verts
[ed
[0]], bm
.verts
[ed
[1]]))
864 bm
.edges
.new((bm
.verts
[-1], bm
.verts
[0]))
865 bm
.edges
.ensure_lookup_table()
869 def update(self
, context
, manipulable_refresh
=False, update_parent
=False):
871 o
= self
.find_in_selection(context
, self
.auto_update
)
876 # clean up manipulators before any data model change
877 if manipulable_refresh
:
878 self
.manipulable_disable(context
)
885 g
= self
.get_generator()
886 g
.locate_manipulators()
888 # vertex index in order to build axis
889 g
.get_verts(verts
, edges
)
892 self
.make_surface(o
, verts
, edges
)
894 # enable manipulators rebuild
895 if manipulable_refresh
:
896 self
.manipulable_refresh
= True
898 # update parent on direct edit
899 if manipulable_refresh
or update_parent
:
900 self
.update_parent(context
, o
)
903 self
.restore_context(context
)
905 def manipulable_setup(self
, context
):
907 self
.manipulable_disable(context
)
910 n_parts
= self
.n_parts
+ 1
912 self
.setup_manipulators()
914 for i
, part
in enumerate(self
.parts
):
919 self
.manip_stack
.append(part
.manipulators
[0].setup(context
, o
, part
))
922 self
.manip_stack
.append(part
.manipulators
[1].setup(context
, o
, part
))
924 self
.manip_stack
.append(part
.manipulators
[3].setup(context
, o
, self
))
926 # self.manip_stack.append(part.manipulators[4].setup(context, o, part))
929 self
.manip_stack
.append(part
.manipulators
[2].setup(context
, o
, self
))