def make_bend( vmf: VMF, origin_a: Vec, origin_b: Vec, norm_a: Vec, norm_b: Vec, config, max_size: int, is_start=False, ) -> None: """Make a corner and the straight sections leading into it.""" off = origin_b - origin_a # The distance to move first, then second. first_movement = round(Vec.dot(off, norm_a)) sec_movement = round(Vec.dot(off, norm_b)) # The size of the corner ranges from 1-3. It's # limited by the user's setting and the distance we have in each direction corner_size = int( min( first_movement // 128, sec_movement // 128, 3, max_size + 1, )) straight_a = first_movement - (corner_size - 1) * 128 straight_b = sec_movement - corner_size * 128 if corner_size < 1: return # No room! if straight_a > 0: make_straight( vmf, origin_a, norm_a, straight_a, config, is_start, ) corner_origin = origin_a + norm_a * straight_a make_corner( vmf, corner_origin, norm_a, norm_b, corner_size - 1, config, ) if straight_b > 0: make_straight( vmf, origin_b - (straight_b * norm_b), norm_b, straight_b, config, )
def _texture_fit( self, side: Side, tex_size: float, field_length: float, fizz: Fizzler, neg: Vec, pos: Vec, is_laserfield=False, ) -> None: """Calculate the texture offsets required for fitting a texture.""" if side.vaxis.vec() != -fizz.up_axis: # Rotate it rot_angle = side.normal().rotation_around() for _ in range(4): side.uaxis = side.uaxis.rotate(rot_angle) side.vaxis = side.vaxis.rotate(rot_angle) if side.vaxis.vec() == -fizz.up_axis: break else: LOGGER.warning("Can't fix rotation for {} -> {}", side.vaxis, fizz.up_axis) side.uaxis.offset = -(tex_size / field_length) * neg.dot(side.uaxis.vec()) side.vaxis.offset = -(tex_size / 128) * neg.dot(side.vaxis.vec()) # The above fits it correctly, except it's vertically half-offset. # For laserfields that's what we want, for fizzlers we want it normal. if not is_laserfield: side.vaxis.offset += tex_size / 2 side.uaxis.scale = field_length / tex_size side.vaxis.scale = 128 / tex_size side.uaxis.offset %= tex_size side.vaxis.offset %= tex_size
def compute_visleafs( coll_data: List[Tuple[Vec, float, Vec, float]], vis_tree_top: VisTree, ) -> List[int]: """Compute the visleafs this rope is present in.""" # Each tree node defines a plane. For each side we touch, we need to # continue looking down that side of the tree for visleafs. # We need to do this individually for each segment pair. That way # we correctly handle cases like ropes encircling a room without entering it. used_leafs: Set[int] = set() # Check if we collide with either side of the tree (or both). # This just involves doing a sphere-plane check with each side of the node. # If both are on one side, the whole segment cannot cross. for point1, radius1, point2, radius2 in coll_data: todo_trees: List[VisTree] = [vis_tree_top] for tree in todo_trees: off1 = Vec.dot(tree.plane_norm, point1) - tree.plane_dist off2 = Vec.dot(tree.plane_norm, point2) - tree.plane_dist if off1 >= -radius1 or off2 >= -radius2: if isinstance(tree.child_neg, VisLeaf): used_leafs.add(tree.child_neg.id) else: todo_trees.append(tree.child_neg) if off1 <= radius1 or off2 <= radius2: if isinstance(tree.child_pos, VisLeaf): used_leafs.add(tree.child_pos.id) else: todo_trees.append(tree.child_pos) return sorted(used_leafs)
def make_cap(orig, norm): # Recompute the UVs to use the first bit of the cable. points = [ Vertex( point.pos, norm, lerp(Vec.dot(point.norm, node.orient.up()), -1, 1, node.config.u_min, node.config.u_max), lerp(Vec.dot(point.norm, node.orient.left()), -1, 1, 0, v_max), point.links, ) for point in orig ] mesh.triangles.append(Triangle(mat, points[0], points[1], points[2])) for a, b in zip(points[2:], points[3:]): mesh.triangles.append(Triangle(mat, points[0], a, b))
def compute_orients(nodes: Iterable[Node]) -> None: """Compute the appropriate orientation for each node.""" # This is based on the info at: # https://janakiev.com/blog/framing-parametric-curves/ tangents: Dict[Node, Vec] = {} all_nodes: Set[Node] = set() for node in nodes: if node.prev is node.next is None: continue node_prev = node.prev if node.prev is not None else node node_next = node.next if node.next is not None else node tangents[node] = (node_next.pos - node_prev.pos).norm() all_nodes.add(node) while all_nodes: node1 = all_nodes.pop() node1 = node1.find_start() tanj1 = tangents[node1] # Start with an arbitrary roll for the first orientation. node1.orient = Matrix.from_angle(tanj1.to_angle()) while node1.next is not None: node2 = node1.next all_nodes.discard(node2) tanj1 = tangents[node1] tanj2 = tangents[node2] b = Vec.cross(tanj1, tanj2) if b.mag_sq() < 0.001: node2.orient = node1.orient.copy() else: b = b.norm() phi = math.acos(Vec.dot(tanj1, tanj2)) up = node1.orient.up() @ Matrix.axis_angle( b, math.degrees(phi)) node2.orient = Matrix.from_basis(x=tanj2, z=up) node1 = node2
def bsp_collision(point: Vec, planes: List[Tuple[Vec, Vec]]) -> bool: """Check if the given position is inside a BSP node.""" for pos, norm in planes: off = pos - point # This is the actual distance, so we'll use a rather large # "epsilon" to catch objects close to the edges. if Vec.dot(off, norm) < -0.1: return False return True
def contains(self, point: Vec) -> bool: """Check if the given position is inside the volume.""" for convex in self.collision: for pos, norm in convex: off = pos - point # This is the actual distance, so we'll use a rather large # "epsilon" to catch objects close to the edges. if Vec.dot(off, norm) < -0.1: break # Outside a plane, it doesn't match this convex. else: # Inside all these planes, it's inside. return True return False # All failed, not present.
def res_transfer_bullseye(inst: Entity, props: Property): """Transfer catapult targets and placement helpers from one tile to another.""" start_pos = conditions.resolve_offset(inst, props['start_pos', '']) end_pos = conditions.resolve_offset(inst, props['end_pos', '']) start_norm = props.vec('start_norm', 0, 0, 1).rotate_by_str(inst['angles']) end_norm = props.vec('end_norm', 0, 0, 1).rotate_by_str(inst['angles']) try: start_tile = tiling.TILES[ (start_pos - 64 * start_norm).as_tuple(), start_norm.as_tuple() ] except KeyError: LOGGER.warning('"{}": Cannot find tile to transfer from at {}, {}!'.format( inst['targetname'], start_pos, start_norm )) return end_tile = tiling.TileDef.ensure( end_pos - 64 * end_norm, end_norm, ) # Now transfer the stuff. if start_tile.has_oriented_portal_helper: # We need to rotate this. orient = start_tile.portal_helper_orient.copy() # If it's directly opposite, just mirror - we have no clue what the # intent is. if Vec.dot(start_norm, end_norm) != -1.0: # Use the dict to compute the rotation to apply. orient.rotate(*NORM_ROTATIONS[ start_norm.as_tuple(), end_norm.as_tuple() ]) end_tile.add_portal_helper(orient) elif start_tile.has_portal_helper: # Non-oriented, don't orient. end_tile.add_portal_helper() start_tile.remove_portal_helper(all=True) if start_tile.bullseye_count: end_tile.bullseye_count = start_tile.bullseye_count start_tile.bullseye_count = 0 # Then transfer the targets across. for plate in faithplate.PLATES.values(): if getattr(plate, 'target', None) is start_tile: plate.target = end_tile
def find_closest( all_nodes: Iterable[Tuple[Vec, List[Tuple[Vec, nodes.Node]]]], node: nodes.Node, src_point: Vec, src_norm: Vec, ) -> nodes.Node: """Search through all the nodes to find the one most aligned to this.""" best_node: Optional[nodes.Node] = None best_dist = math.inf # We're looking for if the point is inside the cylinder projecting out # of the node. for targ_norm, node_lst in all_nodes: if Vec.dot(src_norm, targ_norm) < ANG_THRESHOLD: continue for targ_point, targ in node_lst: if node is targ: continue # First check if we're beyond the target point off = (src_point - targ_point) dist = -off.dot(src_norm) # On the other side, or not better than what we've got. # Allow a tiny amount of overlap. if dist < -2.0 or dist > best_dist: continue # Now project the point onto the target's plane. # If inside, we've found it. if (off + dist * targ_norm).mag_sq() <= (64 * 64): best_node = targ best_dist = dist if best_node is None: if node.ent['targetname']: name = ' "{}"'.format(node.ent["targetname"]) else: name = '' raise ValueError('No destination found for ' f'junction {name} at ({node.origin})!') # Mark the node as having an input, for sanity checking purposes. # Note that nodes can have multiple inputs, if they're merging paths. best_node.has_input = True return best_node
def interpolate_all(nodes: Set[Node]) -> None: """Produce nodes in-between each user-made node.""" # Create the nodes and put them in a seperate list, then add them # to the actual nodes list second. This way sections that have been interpolated # don't affect the interpolation of neighbouring sections. segments: List[List[Node]] = [] for node1 in nodes: if node1.next is None or node1.config.segments <= 0: continue node2 = node1.next interp_type = node1.config.interp func = globals()['interpolate_' + interp_type.name.casefold()] points = func(node1, node2, node1.config.segments) for a, b in zip(points, points[1:]): a.next = b b.prev = a points[0].prev = node1 points[-1].next = node2 segments.append(points) for points in segments: nodes.update(points) points[0].prev.next = points[0] points[-1].next.prev = points[-1] # Finally, split nodes with too much of an angle between them - we can't smooth. for node in list(nodes): if node.prev is None or node.next is None: continue off1 = node.pos - node.prev.pos off2 = node.next.pos - node.pos if Vec.dot(off1, off2) < 0.7: new_node = Node(node.pos.copy(), node.config, node.radius) nodes.add(new_node) new_node.next = node.next node.next.prev = new_node node.next = None
def find_closest( all_nodes: Iterable[Tuple[Vec, List[Tuple[Vec, nodes.Node]]]], node: nodes.Node, src_type: nodes.DestType, ) -> nodes.Node: """Search through all the nodes to find the one most aligned to this.""" src_point = node.vec_point(1.0, src_type) src_norm = node.output_norm(src_type) best_node: Optional[nodes.Node] = None best_dist = math.inf # We're looking for if the point is inside the cylinder projecting out # of the node. for targ_norm, node_lst in all_nodes: if Vec.dot(src_norm, targ_norm) < ANG_THRESHOLD: continue for targ_point, targ in node_lst: if node is targ: continue # First check if we're beyond the target point off = (src_point - targ_point) dist = -off.dot(src_norm) # On the other side, or not better than what we've got. # Allow a tiny amount of overlap. if dist < -8.0 or dist > best_dist: continue # Now project the point onto the target's plane. # If inside, we've found it. if (off + dist * targ_norm).mag_sq() <= (64 * 64): best_node = targ best_dist = dist if best_node is None: raise ValueError( f'No destination found for {src_type.value} output of {node}! Junction at {src_point}.' ) return best_node
def make_barriers(vmf: VMF): """Make barrier entities. get_tex is vbsp.get_tex.""" glass_temp = template_brush.get_scaling_template( options.get(str, "glass_template")) grate_temp = template_brush.get_scaling_template( options.get(str, "grating_template")) hole_temp_small: List[Solid] hole_temp_lrg_diag: List[Solid] hole_temp_lrg_cutout: List[Solid] hole_temp_lrg_square: List[Solid] # Avoid error without this package. if HOLES: # Grab the template solids we need. hole_combined_temp = template_brush.get_template( options.get(str, 'glass_hole_temp')) hole_world, hole_detail, _ = hole_combined_temp.visgrouped({'small'}) hole_temp_small = hole_world + hole_detail hole_world, hole_detail, _ = hole_combined_temp.visgrouped( {'large_diagonal'}) hole_temp_lrg_diag = hole_world + hole_detail hole_world, hole_detail, _ = hole_combined_temp.visgrouped( {'large_cutout'}) hole_temp_lrg_cutout = hole_world + hole_detail hole_world, hole_detail, _ = hole_combined_temp.visgrouped( {'large_square'}) hole_temp_lrg_square = hole_world + hole_detail else: hole_temp_small = hole_temp_lrg_diag = hole_temp_lrg_cutout = hole_temp_lrg_square = [] floorbeam_temp = options.get(str, 'glass_floorbeam_temp') if options.get_itemconf('BEE_PELLET:PelletGrating', False): # Merge together these existing filters in global_pti_ents vmf.create_ent( origin=options.get(Vec, 'global_pti_ents_loc'), targetname='@grating_filter', classname='filter_multi', filtertype=0, negated=0, filter01='@not_pellet', filter02='@not_paint_bomb', ) else: # Just skip paint bombs. vmf.create_ent( origin=options.get(Vec, 'global_pti_ents_loc'), targetname='@grating_filter', classname='filter_activator_class', negated=1, filterclass='prop_paint_bomb', ) # Group the positions by planes in each orientation. # This makes them 2D grids which we can optimise. # (normal_dist, positive_axis, type) -> [(x, y)] slices: Dict[Tuple[Tuple[float, float, float], bool, BarrierType], Dict[Tuple[int, int], False]] = defaultdict(dict) # We have this on the 32-grid so we can cut squares for holes. for (origin_tup, normal_tup), barr_type in BARRIERS.items(): origin = Vec(origin_tup) normal = Vec(normal_tup) norm_axis = normal.axis() u, v = origin.other_axes(norm_axis) norm_pos = Vec.with_axes(norm_axis, origin) slice_plane = slices[ norm_pos.as_tuple(), # distance from origin to this plane. normal[norm_axis] > 0, barr_type, ] for u_off in [-48, -16, 16, 48]: for v_off in [-48, -16, 16, 48]: slice_plane[int((u + u_off) // 32), int((v + v_off) // 32), ] = True # Remove pane sections where the holes are. We then generate those with # templates for slanted parts. for (origin_tup, norm_tup), hole_type in HOLES.items(): barr_type = BARRIERS[origin_tup, norm_tup] origin = Vec(origin_tup) normal = Vec(norm_tup) norm_axis = normal.axis() u, v = origin.other_axes(norm_axis) norm_pos = Vec.with_axes(norm_axis, origin) slice_plane = slices[norm_pos.as_tuple(), normal[norm_axis] > 0, barr_type, ] if hole_type is HoleType.LARGE: offsets = (-80, -48, -16, 16, 48, 80) else: offsets = (-16, 16) for u_off in offsets: for v_off in offsets: # Remove these squares, but keep them in the dict # so we can check if there was glass there. uv = ( int((u + u_off) // 32), int((v + v_off) // 32), ) if uv in slice_plane: slice_plane[uv] = False # These have to be present, except for the corners # on the large hole. elif abs(u_off) != 80 or abs(v_off) != 80: u_ax, v_ax = Vec.INV_AXIS[norm_axis] LOGGER.warning( 'Hole tried to remove missing tile at ({})?', Vec.with_axes(norm_axis, norm_pos, u_ax, u + u_off, v_ax, v + v_off), ) # Now generate the curved brushwork. if barr_type is BarrierType.GLASS: front_temp = glass_temp elif barr_type is BarrierType.GRATING: front_temp = grate_temp else: raise NotImplementedError angles = normal.to_angle() hole_temp: List[Tuple[List[Solid], Matrix]] = [] # This is a tricky bit. Two large templates would collide # diagonally, and we allow the corner glass to not be present since # the hole doesn't actually use that 32x32 segment. # So we need to determine which of 3 templates to use. corn_angles = angles.copy() if hole_type is HoleType.LARGE: for corn_angles.roll in (0, 90, 180, 270): corn_mat = Matrix.from_angle(corn_angles) corn_dir = Vec(y=1, z=1) @ corn_angles hole_off = origin + 128 * corn_dir diag_type = HOLES.get( (hole_off.as_tuple(), normal.as_tuple()), None, ) corner_pos = origin + 80 * corn_dir corn_u, corn_v = corner_pos.other_axes(norm_axis) corn_u = int(corn_u // 32) corn_v = int(corn_v // 32) if diag_type is HoleType.LARGE: # There's another large template to this direction. # Just have 1 generate both combined, so the brushes can # be more optimal. To pick, arbitrarily make the upper one # be in charge. if corn_v > v // 32: hole_temp.append((hole_temp_lrg_diag, corn_mat)) continue if (corn_u, corn_v) in slice_plane: hole_temp.append((hole_temp_lrg_square, corn_mat)) else: hole_temp.append((hole_temp_lrg_cutout, corn_mat)) else: hole_temp.append((hole_temp_small, Matrix.from_angle(angles))) def solid_pane_func(off1: float, off2: float, mat: str) -> List[Solid]: """Given the two thicknesses, produce the curved hole from the template.""" off_min = 64 - max(off1, off2) off_max = 64 - min(off1, off2) new_brushes = [] for brushes, matrix in hole_temp: for orig_brush in brushes: brush = orig_brush.copy(vmf_file=vmf) new_brushes.append(brush) for face in brush.sides: face.mat = mat for point in face.planes: if point.x > 64: point.x = off_max else: point.x = off_min face.localise(origin, matrix) # Increase precision, these are small detail brushes. face.lightmap = 8 return new_brushes make_glass_grating( vmf, origin, normal, barr_type, front_temp, solid_pane_func, ) for (plane_pos, is_pos, barr_type), pos_slice in slices.items(): plane_pos = Vec(plane_pos) norm_axis = plane_pos.axis() normal = Vec.with_axes(norm_axis, 1 if is_pos else -1) if barr_type is BarrierType.GLASS: front_temp = glass_temp elif barr_type is BarrierType.GRATING: front_temp = grate_temp else: raise NotImplementedError u_axis, v_axis = Vec.INV_AXIS[norm_axis] for min_u, min_v, max_u, max_v in grid_optimise(pos_slice): # These are two points in the origin plane, at the borders. pos_min = Vec.with_axes( norm_axis, plane_pos, u_axis, min_u * 32, v_axis, min_v * 32, ) pos_max = Vec.with_axes( norm_axis, plane_pos, u_axis, max_u * 32 + 32, v_axis, max_v * 32 + 32, ) def solid_pane_func(pos1: float, pos2: float, mat: str) -> List[Solid]: """Make the solid brush.""" return [ vmf.make_prism( pos_min + normal * (64.0 - pos1), pos_max + normal * (64.0 - pos2), mat=mat, ).solid ] make_glass_grating( vmf, (pos_min + pos_max) / 2 + 63 * normal, normal, barr_type, front_temp, solid_pane_func, ) # Generate hint brushes, to ensure sorting is done correctly. [hint] = solid_pane_func(0, 4.0, consts.Tools.SKIP) for side in hint: if abs(Vec.dot(side.normal(), normal)) > 0.99: side.mat = consts.Tools.HINT vmf.add_brush(hint) if floorbeam_temp: LOGGER.info('Adding Glass floor beams...') add_glass_floorbeams(vmf, floorbeam_temp) LOGGER.info('Done!')
def join_markers(vmf: VMF, mark_a: Marker, mark_b: Marker, is_start: bool=False) -> None: """Join two marker ents together with corners.""" origin_a = Vec.from_str(mark_a.ent['origin']) origin_b = Vec.from_str(mark_b.ent['origin']) norm_a = mark_a.orient.forward() norm_b = mark_b.orient.forward() config = mark_a.conf LOGGER.debug( 'Connect markers: {} @ {} -> {} @ {}, dot={}\n{}', origin_a, norm_a, origin_b, norm_b, Vec.dot(norm_a, norm_b), config, ) if norm_a == norm_b: # Either straight-line, or s-bend. dist = round((origin_a - origin_b).mag()) if origin_a + (norm_a * dist) == origin_b: make_straight( vmf, origin_a, norm_a, dist, config, is_start, ) # else: S-bend, we don't do the geometry for this.. return if norm_a == -norm_b: # U-shape bend.. make_ubend( vmf, origin_a, origin_b, norm_a, config, max_size=mark_a.size, ) return # Lastly try a regular curve. Check they are on the same plane. side_dir = Vec.cross(norm_a, norm_b) side_off_a = side_dir.dot(origin_a) side_off_b = side_dir.dot(origin_b) if abs(side_off_a - side_off_b) < 1e-6: make_bend( vmf, origin_a, origin_b, norm_a, norm_b, config, max_size=mark_a.size, ) else: LOGGER.warning( 'Cannot connect markers: {} @ {} -> {} @ {}\n ' 'Sides: {:.12f} {:.12f}\n{}', origin_a, norm_a, origin_b, norm_b, side_off_a, side_off_b, config, )
def entity_finder(ctx: Context): """Finds the closest entity of a given type.""" target_cache = {} # type: Dict[tuple, Entity] for finder in ctx.vmf.by_class['comp_entity_finder']: finder.remove() targ_class = finder['targetcls'] targ_radius = conv_float(finder['radius']) targ_ref = finder['targetref'] blacklist = finder['blacklist'].casefold() # Restrict to actually valid dot products. targ_fov = max( 0.0, min(180.0, conv_float(finder['searchfov', '180.0'], 180.0))) # This will never find things, ignore that. if targ_fov == 0.0: LOGGER.warning( 'Entity finder at <{}>! has FOV of 0, ignoring!', finder['origin'], ) targ_fov = 180.0 targ_dot = math.cos(math.radians(targ_fov)) normal = Vec(x=1).rotate_by_str(finder['angles']) targ_pos = Vec.from_str(finder['origin']) if targ_ref: for ent in ctx.vmf.search(targ_ref): targ_pos = Vec.from_str(ent['origin']) break else: LOGGER.warning( 'Can\'t find ref entity named "{}" ' 'for entity finder at <{}>!', targ_ref, finder['origin'], ) key = (targ_class, targ_radius, blacklist, targ_fov) + targ_pos.as_tuple() try: found_ent = target_cache[key] except KeyError: found_ent = None cur_dist = float('inf') targ_ent = None if blacklist.endswith('*'): blacklist = blacklist[:-1] def blacklist_func(name: str) -> bool: """Check if the name matches the blacklist, with wildcards.""" return name.casefold().startswith(blacklist) elif blacklist: def blacklist_func(name: str) -> bool: """Check if the name matches the blacklist exactly.""" return name.casefold() == blacklist else: def blacklist_func(name: str) -> bool: """No blacklist.""" return False for targ_ent in ctx.vmf.by_class[targ_class]: if blacklist_func(targ_ent['targetname']): continue offset = (Vec.from_str(targ_ent['origin']) - targ_pos) dist_to = offset.mag() # If at the same point, direction is meaningless. # Treat that as always passing the FOV check. if dist_to != 0.0 and Vec.dot(offset.norm(), normal) < targ_dot: continue if targ_radius == 0 or dist_to < targ_radius: if cur_dist > dist_to: found_ent = targ_ent cur_dist = dist_to del targ_ent if found_ent is None: LOGGER.warning( 'Cannot find valid {} entity within {} units ' 'for entity finder at <{}>! (fov={}, in direction {})', targ_class, targ_radius, finder['origin'], targ_fov, normal, ) continue target_cache[key] = found_ent found_ent.outputs.extend(finder.outputs) finder.outputs.clear() # If the ent has no targetname, give it one. if not found_ent['targetname']: found_ent['targetname'] = '_found_entity_1' found_ent.make_unique() # If specified, teleport to the item's location. if conv_bool(finder['teleporttarget']): found_ent['origin'] = targ_pos for ind in itertools.count(1): kv_mode_str = finder['kv{}_mode'.format(ind)].casefold() if kv_mode_str == '': break try: kv_mode = FinderModes(kv_mode_str) except ValueError: LOGGER.warning( 'Unknown mode "{}" ' 'for entity finder at <{}>!', kv_mode_str, finder['origin'], ) continue kv_src = finder['kv{}_src'.format(ind)] kv_dest = finder['kv{}_dest'.format(ind)] # All modes need the destination keyvalue. if not kv_dest: LOGGER.warning( 'No destination keyvalue set ' 'for entity finder at <{}>, transformation #{}!', finder['origin'], ind, ) continue needs_src, needs_known = NEEDS[kv_mode] known_ent = None if needs_known: known_ent_name = finder['kv{}_known'.format(ind)] if not known_ent_name: LOGGER.warning( 'No known entity specified for entity finder at ' '<{}>, but one required for transformation #{}!', finder['origin'], ind, ) for known_ent in ctx.vmf.search(known_ent_name): break if known_ent is None: LOGGER.warning( 'Can\'t find known entity named ' '"{}" for entity finder at <{}>, transformation #{}!', known_ent_name, finder['origin'], ind, ) continue if needs_src and not kv_src: LOGGER.warning( 'No source keyvalue set ' 'for entity finder at <{}>, transformation {}!', finder['origin'], ind, ) continue if kv_mode is FinderModes.CONST_TARG: # Set constant value on the found ent. found_ent[kv_dest] = kv_src elif kv_mode is FinderModes.CONST_KNOWN: # Set constant value on known entity. known_ent[kv_dest] = kv_src elif kv_mode is FinderModes.TARG_TO_KNOWN: known_ent[kv_dest] = found_ent[kv_src] elif kv_mode is FinderModes.KNOWN_TO_TARG: found_ent[kv_dest] = known_ent[kv_src] elif kv_mode is FinderModes.OUTPUT_MERGE: name = '!' + kv_dest.lstrip('!').casefold() for out in known_ent.outputs: if out.target.casefold() == name: out.target = found_ent['targetname'] else: raise AssertionError('Unknown mode {}'.format(kv_mode))
def generate(sources: List[nodes.Spawner]) -> List[Animation]: """Generate all the animations, one by one.""" anims = [Animation(node) for node in sources] for anim in anims: node = anim.cur_node speed = anim.start_node.speed / FPS offset = anim.start_node.origin.copy() # To keep the speed constant, keep track of any extra we need to offset # into the next node. overshoot = anim.start_overshoot # To generate, we alternate between making a single node, and then # making the straight section. while True: # First, check to see if we need to branch off. # If we're secondary, we are the branch off and so don't need to # do that again. if anim.curve_type is DestType.PRIMARY: if DestType.SECONDARY in node.out_types: anims.append(anim.tee(node, DestType.SECONDARY, overshoot)) if DestType.TERTIARY in node.out_types: anims.append(anim.tee(node, DestType.TERTIARY, overshoot)) needs_out = node.has_pass overshoot = 0 seg_len = node.path_len(anim.curve_type) seg_frames = math.ceil((seg_len - overshoot) / speed) for i in range(int(seg_frames)): # Make each frame. pos = (overshoot + speed * i) / seg_len if needs_out and pos > 0.5: anim.pass_points.append((anim.duration, node)) needs_out = False # Place the point. last_loc = node.vec_point(pos, anim.curve_type) anim.add_point(last_loc - offset) # If short, we might not have placed the output. if needs_out: anim.pass_points.append((anim.duration, node)) # Recalculate the new overshoot. overshoot += speed * seg_frames - seg_len overshoot = 0 if isinstance(node, nodes.Destroyer): # We reached the end, finalise! anim.end_node = node anim.add_point(node.origin - offset) break # Now generate the straight part between this node and the next. next_node = node.outputs[anim.curve_type] cur_end = node.vec_point(1.0, anim.curve_type) straight_off = next_node.vec_point(0.0) - cur_end if next_node in anim.history: raise ValueError(f"Vactube junction at {next_node.origin} " f"loops back onto itself!") # Only generate the straight part if the nodes aren't overlapping. if Vec.dot(straight_off, next_node.input_norm()) > 0: straight_dist = straight_off.mag() seg_frames = math.ceil((straight_dist - overshoot) / speed) for i in range(int(seg_frames)): # Make each frame. pos = cur_end + ( (overshoot + speed * i) / straight_dist) * straight_off anim.add_point(pos - offset) overshoot += (speed * seg_frames) - straight_dist # And advance to the next node. anim.cur_node = node = next_node anim.history.append(node) # We only do secondary for the first node, we always continue # to the primary value. anim.curve_type = DestType.PRIMARY return anims
def make_glass_grating( vmf: VMF, ent_pos: Vec, normal: Vec, barr_type: BarrierType, front_temp: template_brush.ScalingTemplate, solid_func: Callable[[float, float, str], List[Solid]], ): """Make all the brushes needed for glass/grating. solid_func() is called with two offsets from the voxel edge, and returns a matching list of solids. This allows doing holes and normal panes with the same function. barrier_type is either 'glass' or 'grating'. """ if barr_type is BarrierType.GLASS: main_ent = vmf.create_ent('func_detail') player_clip_mat = consts.Tools.PLAYER_CLIP_GLASS tex_cat = 'glass' else: player_clip_mat = consts.Tools.PLAYER_CLIP_GRATE main_ent = vmf.create_ent( 'func_brush', renderfx=14, # Constant Glow solidity=1, # Never solid origin=ent_pos, ) tex_cat = 'grating' # The actual glass/grating brush - 0.5-1.5 units back from the surface. main_ent.solids = solid_func(0.5, 1.5, consts.Tools.NODRAW) for face in main_ent.sides(): if abs(Vec.dot(normal, face.normal())) > 0.99: texturing.apply(texturing.GenCat.SPECIAL, face, tex_cat) front_temp.apply(face, change_mat=False) if normal.z == 0: # If vertical, we don't care about footsteps. # So just use 'normal' clips. player_clip = vmf.create_ent('func_detail') player_clip_mat = consts.Tools.PLAYER_CLIP else: # This needs to be a func_brush, otherwise the clip texture data # will be merged with other clips. player_clip = vmf.create_ent( 'func_brush', solidbsp=1, origin=ent_pos, ) # We also need a func_detail clip, which functions on portals. # Make it thinner, so it doesn't impact footsteps. player_thin_clip = vmf.create_ent('func_detail') player_thin_clip.solids = solid_func(0.5, 3.5, consts.Tools.PLAYER_CLIP) player_clip.solids = solid_func(0, 4, player_clip_mat) if barr_type is BarrierType.GRATING: # Add the VPhysics clip. phys_clip = vmf.create_ent( 'func_clip_vphysics', filtername='@grating_filter', origin=ent_pos, StartDisabled=0, ) phys_clip.solids = solid_func(0, 2, consts.Tools.TRIGGER)
def res_antlaser(vmf: VMF, res: Property) -> object: """The condition to generate AntLasers and Antline Corners. This is executed once to modify all instances. """ conf_inst_corner = instanceLocs.resolve('<item_bee2_antline_corner>', silent=True) conf_inst_laser = instanceLocs.resolve(res['instance']) conf_glow_height = Vec(z=res.float('GlowHeight', 48) - 64) conf_las_start = Vec(z=res.float('LasStart') - 64) conf_rope_off = res.vec('RopePos') conf_toggle_targ = res['toggleTarg', ''] beam_conf = res.find_key('BeamKeys', or_blank=True) glow_conf = res.find_key('GlowKeys', or_blank=True) cable_conf = res.find_key('CableKeys', or_blank=True) if beam_conf: # Grab a copy of the beam spawnflags so we can set our own options. conf_beam_flags = beam_conf.int('spawnflags') # Mask out certain flags. conf_beam_flags &= ( 0 | 1 # Start On | 2 # Toggle | 4 # Random Strike | 8 # Ring | 16 # StartSparks | 32 # EndSparks | 64 # Decal End #| 128 # Shade Start #| 256 # Shade End #| 512 # Taper Out ) else: conf_beam_flags = 0 conf_outputs = [ Output.parse(prop) for prop in res if prop.name in ('onenabled', 'ondisabled') ] # Find all the markers. nodes: dict[str, Node] = {} for inst in vmf.by_class['func_instance']: filename = inst['file'].casefold() name = inst['targetname'] if filename in conf_inst_laser: node_type = NodeType.LASER elif filename in conf_inst_corner: node_type = NodeType.CORNER else: continue try: # Remove the item - it's no longer going to exist after # we're done. item = connections.ITEMS.pop(name) except KeyError: raise ValueError('No item for "{}"?'.format(name)) from None pos = Vec.from_str(inst['origin']) orient = Matrix.from_angle(Angle.from_str(inst['angles'])) if node_type is NodeType.CORNER: timer_delay = item.inst.fixup.int('$timer_delay') # We treat inf, 1, 2 and 3 as the same, to get around the 1 and 2 not # being selectable issue. pos = CORNER_POS[max(0, timer_delay - 3) % 8] @ orient + pos nodes[name] = Node(node_type, inst, item, pos, orient) if not nodes: # None at all. return conditions.RES_EXHAUSTED # Now find every connected group, recording inputs, outputs and links. todo = set(nodes.values()) groups: list[Group] = [] while todo: start = todo.pop() # Synthesise the Item used for logic. # We use a random info_target to manage the IO data. group = Group(start, start.type) groups.append(group) for node in group.nodes: # If this node has no non-node outputs, destroy the antlines. has_output = False node.is_grouped = True for conn in list(node.item.outputs): neighbour = conn.to_item neigh_node = nodes.get(neighbour.name, None) todo.discard(neigh_node) if neigh_node is None or neigh_node.type is not node.type: # Not a node or different item type, it must therefore # be a target of our logic. conn.from_item = group.item has_output = True continue elif not neigh_node.is_grouped: # Another node. group.nodes.append(neigh_node) # else: True, node already added. # For nodes, connect link. conn.remove() group.links.add(frozenset({node, neigh_node})) # If we have a real output, we need to transfer it. # Otherwise we can just destroy it. if has_output: node.item.transfer_antlines(group.item) else: node.item.delete_antlines() # Do the same for inputs, so we can catch that. for conn in list(node.item.inputs): neighbour = conn.from_item neigh_node = nodes.get(neighbour.name, None) todo.discard(neigh_node) if neigh_node is None or neigh_node.type is not node.type: # Not a node or different item type, it must therefore # be a target of our logic. conn.to_item = group.item node.had_input = True continue elif not neigh_node.is_grouped: # Another node. group.nodes.append(neigh_node) # else: True, node already added. # For nodes, connect link. conn.remove() group.links.add(frozenset({neigh_node, node})) # Now every node is in a group. Generate the actual entities. for group in groups: # We generate two ent types. For each marker, we add a sprite # and a beam pointing at it. Then for each connection # another beam. # Choose a random item name to use for our group. base_name = group.nodes[0].item.name out_enable = [Output('', '', 'FireUser2')] out_disable = [Output('', '', 'FireUser1')] if group.type is NodeType.LASER: for output in conf_outputs: if output.output.casefold() == 'onenabled': out_enable.append(output.copy()) else: out_disable.append(output.copy()) group.item.enable_cmd = tuple(out_enable) group.item.disable_cmd = tuple(out_disable) if group.type is NodeType.LASER and conf_toggle_targ: # Make the group info_target into a texturetoggle. toggle = group.item.inst toggle['classname'] = 'env_texturetoggle' toggle['target'] = conditions.local_name(group.nodes[0].inst, conf_toggle_targ) # Node -> index for targetnames. indexes: dict[Node, int] = {} # For antline corners, the antline segments. segments: list[antlines.Segment] = [] # frozenset[Node] unpacking isn't clear. node_a: Node node_b: Node if group.type is NodeType.CORNER: for node_a, node_b in group.links: # Place a straight antline between each connected node. # If on the same plane, we only need one. If not, we need to # do one for each plane it's in. offset = node_b.pos - node_a.pos up_a = node_a.orient.up() up_b = node_b.orient.up() plane_a = Vec.dot(node_a.pos, up_a) plane_b = Vec.dot(node_b.pos, up_b) if Vec.dot(up_a, up_b) > 0.9: if abs(plane_a - plane_b) > 1e-6: LOGGER.warning( 'Antline corners "{}" - "{}" ' 'are on different planes', node_a.item.name, node_b.item.name, ) continue u = node_a.orient.left() v = node_a.orient.forward() # Which are we aligned to? if abs(Vec.dot(offset, u)) < 1e-6 or abs(Vec.dot( offset, v)) < 1e-6: forward = offset.norm() group.add_ant_straight( up_a, node_a.pos + 8.0 * forward, node_b.pos - 8.0 * forward, ) else: LOGGER.warning( 'Antline corners "{}" - "{}" ' 'are not directly aligned', node_a.item.name, node_b.item.name, ) else: # We expect them be aligned to each other. side = Vec.cross(up_a, up_b) if abs(Vec.dot(side, offset)) < 1e-6: mid1 = node_a.pos + Vec.dot(offset, up_b) * up_b mid2 = node_b.pos - Vec.dot(offset, up_a) * up_a if mid1 != mid2: LOGGER.warning( 'Midpoint mismatch: {} != {} for "{}" - "{}"', mid1, mid2, node_a.item.name, node_b.item.name, ) group.add_ant_straight( up_a, node_a.pos + 8.0 * (mid1 - node_a.pos).norm(), mid1, ) group.add_ant_straight( up_b, node_b.pos + 8.0 * (mid2 - node_b.pos).norm(), mid2, ) # For cables, it's a bit trickier than the beams. # The cable ent itself is the one which decides what it links to, # so we need to potentially make endpoint cables at locations with # only "incoming" lines. # So this dict is either a targetname to indicate cables with an # outgoing connection, or the entity for endpoints without an outgoing # connection. cable_points: dict[Node, Union[Entity, str]] = {} for i, node in enumerate(group.nodes, start=1): indexes[node] = i node.item.name = base_name if group.type is NodeType.CORNER: node.inst.remove() # Figure out whether we want a corner at this point, or # just a regular dot. If a non-node input was provided it's # always a corner. Otherwise it's one if there's an L, T or X # junction. use_corner = True norm = node.orient.up().as_tuple() if not node.had_input: neighbors = [ mag * direction for direction in [ node.orient.forward(), node.orient.left(), ] for mag in [-8.0, 8.0] if ((node.pos + mag * direction).as_tuple(), norm) in group.ant_seg ] if len(neighbors) == 2: [off1, off2] = neighbors if Vec.dot(off1, off2) < -0.99: # ---o---, merge together. The endpoints we want # are the other ends of the two segments. group.add_ant_straight( node.orient.up(), group.rem_ant_straight(norm, node.pos + off1), group.rem_ant_straight(norm, node.pos + off2), ) use_corner = False elif len(neighbors) == 1: # o-----, merge. [offset] = neighbors group.add_ant_straight( node.orient.up(), group.rem_ant_straight(norm, node.pos + offset), node.pos - offset, ) use_corner = False if use_corner: segments.append( antlines.Segment( antlines.SegType.CORNER, round(node.orient.up(), 3), Vec(node.pos), Vec(node.pos), )) elif group.type is NodeType.LASER: sprite_pos = node.pos + conf_glow_height @ node.orient if glow_conf: # First add the sprite at the right height. sprite = vmf.create_ent('env_sprite') for prop in glow_conf: sprite[prop.name] = conditions.resolve_value( node.inst, prop.value) sprite['origin'] = sprite_pos sprite['targetname'] = NAME_SPR(base_name, i) elif beam_conf: # If beams but not sprites, we need a target. vmf.create_ent( 'info_target', origin=sprite_pos, targetname=NAME_SPR(base_name, i), ) if beam_conf: # Now the beam going from below up to the sprite. beam_pos = node.pos + conf_las_start @ node.orient beam = vmf.create_ent('env_beam') for prop in beam_conf: beam[prop.name] = conditions.resolve_value( node.inst, prop.value) beam['origin'] = beam['targetpoint'] = beam_pos beam['targetname'] = NAME_BEAM_LOW(base_name, i) beam['LightningStart'] = beam['targetname'] beam['LightningEnd'] = NAME_SPR(base_name, i) beam['spawnflags'] = conf_beam_flags | 128 # Shade Start segments += set(group.ant_seg.values()) if group.type is NodeType.CORNER and segments: group.item.antlines.add( antlines.Antline(group.item.name + '_antline', segments)) if group.type is NodeType.LASER and beam_conf: for i, (node_a, node_b) in enumerate(group.links): beam = vmf.create_ent('env_beam') conditions.set_ent_keys(beam, node_a.inst, res, 'BeamKeys') beam['origin'] = beam['targetpoint'] = node_a.pos beam['targetname'] = NAME_BEAM_CONN(base_name, i) beam['LightningStart'] = NAME_SPR(base_name, indexes[node_a]) beam['LightningEnd'] = NAME_SPR(base_name, indexes[node_b]) beam['spawnflags'] = conf_beam_flags if group.type is NodeType.LASER and cable_conf: build_cables( vmf, group, cable_points, base_name, beam_conf, conf_rope_off, ) return conditions.RES_EXHAUSTED
def parse_antlines(vmf: VMF) -> tuple[ dict[str, list[Antline]], dict[int, list[Segment]] ]: """Convert overlays in the map into Antline objects. This returns two dicts. The first maps targetnames to lists of antlines. The second maps solid IDs to segments, for assigning TileDefs to them. """ # We want to reconstruct the shape of the antline path. # To do that we find the neighbouring points for each overlay. LOGGER.info('Parsing antlines...') # segment -> found neighbours of it. overlay_joins: defaultdict[Segment, set[Segment]] = defaultdict(set) segment_to_name: dict[Segment, str] = {} # Points on antlines where two can connect. For corners that's each side, # for straight it's each end. Combine that with the targetname # so we only join related antlines. join_points: dict[tuple[str, float, float, float], Segment] = {} mat_straight = consts.Antlines.STRAIGHT mat_corner = consts.Antlines.CORNER side_to_seg: dict[int, list[Segment]] = {} antlines: dict[str, list[Antline]] = {} for over in vmf.by_class['info_overlay']: mat = over['material'] origin = Vec.from_str(over['basisorigin']) normal = Vec.from_str(over['basisnormal']) orient = Matrix.from_angle(Angle.from_str(over['angles'])) if mat == mat_corner: seg_type = SegType.CORNER start = end = origin # One on each side - we know the size. points = [ origin + orient.left(-8.0), origin + orient.left(+8.0), origin + orient.forward(-8.0), origin + orient.forward(+8.0), ] elif mat == mat_straight: seg_type = SegType.STRAIGHT # We want to determine the length first. long_axis = orient.left() side_axis = orient.forward() # The order of these isn't correct, but we need the neighbours to # fix that. start, end = overlay_bounds(over) # For whatever reason, Valve sometimes generates antlines which are # shortened by 1 unit. So snap those to grid. start = round(start / 16, 0) * 16 end = round(end / 16, 0) * 16 if math.isclose(Vec.dot(end - start, long_axis), 16.0): # Special case. # 1-wide antlines don't have the correct # rotation, pointing always in the U axis. # So we need to figure that out to get the correct links. # For now just create the segment with dummy values. start = end = origin points = [] else: offset: Vec = round(abs(8 * side_axis), 0) start += offset end -= offset points = [start, end] else: # It's not an antline. continue seg = Segment(seg_type, normal, start, end) segment_to_name[seg] = over_name = over['targetname'] for side_id in over['sides'].split(): side_to_seg.setdefault(int(side_id), []).append(seg) for point in points: # Lookup the point to see if we've already checked it. # If not, write us into that spot. neighbour = join_points.setdefault( (over_name, ) + point.as_tuple(), seg, ) if neighbour is seg: # None found continue overlay_joins[neighbour].add(seg) overlay_joins[seg].add(neighbour) # Remove original from the map. over.remove() # Now fix the square straight segments. for seg, over_name in segment_to_name.items(): if seg.type is SegType.STRAIGHT and seg.start == seg.end: fix_single_straight(seg, over_name, join_points, overlay_joins) # Now, finally compute each continuous section. for start_seg, over_name in segment_to_name.items(): try: neighbours = overlay_joins[start_seg] except KeyError: continue # Done already. if len(neighbours) != 1: continue # Found a start point! segments = [start_seg] for segment in segments: neighbours = overlay_joins.pop(segment) # Except KeyError: this segment's already done?? for neighbour in neighbours: if neighbour not in segments: segments.append(neighbour) antlines.setdefault(over_name, []).append(Antline(over_name, segments)) LOGGER.info('Done! ({} antlines)'.format(sum(map(len, antlines.values())))) return antlines, side_to_seg