def combine_group( compiler: ModelCompiler, props: List[StaticProp], lookup_model: Callable[[str], Tuple[QC, Model]], ) -> StaticProp: """Merge the given props together, compiling a model if required.""" # We want to allow multiple props to reuse the same model. # To do this try and match prop groups to each other, by "unifying" # them into a consistent orientation. # # If there are matches in different orientations, they're most likely # 90 degree or other rotations in the yaw axis. So we compute the average, # and subtract that out. avg_pos = Vec() avg_yaw = 0.0 visleafs = set() # type: Set[int] for prop in props: avg_pos += prop.origin avg_yaw += prop.angles.yaw visleafs.update(prop.visleafs) # Snap to nearest 15 degrees to keep the models themselves not # strangely rotated. avg_yaw = round(avg_yaw / (15 * len(props))) * 15.0 avg_pos /= len(props) yaw_rot = Matrix.from_yaw(-avg_yaw) prop_pos = set() for prop in props: origin = round((prop.origin - avg_pos) @ yaw_rot, 7) angles = round(Vec(prop.angles), 7) angles.y -= avg_yaw try: coll = CollType(prop.solidity) except ValueError: raise ValueError('Unknown prop_static collision type ' '{} for "{}" at {}!'.format( prop.solidity, prop.model, prop.origin, )) prop_pos.add( PropPos( origin.x, origin.y, origin.z, angles.x, angles.y, angles.z, prop.model, prop.skin, prop.scaling, coll, )) # We don't want to build collisions if it's not used. has_coll = any(pos.solidity is not CollType.NONE for pos in prop_pos) mdl_name, result = compiler.get_model( (frozenset(prop_pos), has_coll), compile_func, lookup_model, ) # Many of these we require to be the same, so we can read them # from any of the component props. return StaticProp( model=mdl_name, origin=avg_pos, angles=Angle(0, avg_yaw - 90, 0), scaling=1.0, visleafs=sorted(visleafs), solidity=(CollType.VPHYS if has_coll else CollType.NONE).value, flags=props[0].flags, lighting_origin=avg_pos, tint=props[0].tint, renderfx=props[0].renderfx, )
def group_props_ent( prop_groups: Dict[Optional[tuple], List[StaticProp]], rejected: List[StaticProp], get_model: Callable[[str], Tuple[Optional[QC], Optional[Model]]], bbox_ents: List[Entity], min_cluster: int, ) -> Iterator[List[StaticProp]]: """Given the groups of props, merge props according to the provided ents.""" # Ents with group names. We have to split those by filter too. grouped_sets: Dict[Tuple[str, FrozenSet[str]], CombineVolume] = {} # Skinset filter -> volumes that match. sets_by_skin: Dict[FrozenSet[str], List[CombineVolume]] = defaultdict(list) empty_fs = frozenset('') for ent in bbox_ents: origin = Vec.from_str(ent['origin']) skinset = empty_fs mdl_name = ent['prop'] if mdl_name: qc, mdl = get_model(mdl_name) if mdl is not None: skinset = frozenset({ tex.casefold().replace('\\', '/') for tex in mdl.iter_textures([conv_int(ent['skin'])]) }) # Compute 6 planes to use for collision detection. mat = Matrix.from_angle(Angle.from_str(ent['angles'])) mins, maxes = Vec.bbox( Vec.from_str(ent['mins']), Vec.from_str(ent['maxs']), ) size = maxes - mins # Enlarge slightly to ensure it never has a zero area. # Otherwise the normal could potentially be invalid. mins -= 0.05 maxes += 0.05 # Group name group_name = ent['name'] if group_name: try: combine_set = grouped_sets[group_name, skinset] except KeyError: combine_set = grouped_sets[group_name, skinset] = CombineVolume( group_name, skinset, origin) sets_by_skin[skinset].append(combine_set) else: combine_set = CombineVolume(group_name, skinset, origin) sets_by_skin[skinset].append(combine_set) combine_set.volume += size.x * size.y * size.z # For each direction, compute a position on the plane and # the normal vector. combine_set.collision.append([( origin + Vec.with_axes(axis, offset) @ mat, Vec.with_axes(axis, norm) @ mat, ) for offset, norm in zip([mins, maxes], (-1, 1)) for axis in ('x', 'y', 'z')]) # We want to apply a ordering to groups, so smaller ones apply first, and # filtered ones override all others. for group_list in sets_by_skin.values(): group_list.sort(key=operator.attrgetter('volume')) # Groups with no filter have no skins in the group. unfiltered_group = sets_by_skin.get(frozenset(), []) # Each of these groups cannot be merged with other ones. for group_key, group in prop_groups.items(): if group_key is None: continue # No point merging single/empty groups. group_skinset = group_key[0] if len(group) < min_cluster: rejected.extend(group) group.clear() continue for combine_set in itertools.chain(sets_by_skin.get(group_skinset, ()), unfiltered_group): found = [] for prop in list(group): if combine_set.contains(prop.origin): found.append(prop) combine_set.used = True actual = set(found).intersection(group) if len(actual) >= min_cluster: yield list(actual) for prop in actual: group.remove(prop) # Finally, reject all the ones not in a bbox. for group in prop_groups.values(): rejected.extend(group) # And log unused groups for combine_set_list in sets_by_skin.values(): for combine_set in combine_set_list: if not combine_set.used: LOGGER.warning('Unused comp_propcombine_set {}', combine_set.desc)
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
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 read_from_map(self, vmf: VMF, has_attr: dict[str, bool], items: dict[str, editoritems.Item]) -> None: """Given the map file, set blocks.""" from precomp.instance_traits import get_item_id from precomp import bottomlessPit # Starting points to fill air and goo. # We want to fill goo first... air_search_locs: list[tuple[Vec, bool]] = [] goo_search_locs: list[tuple[Vec, bool]] = [] for ent in vmf.entities: str_pos = ent['origin', None] if str_pos is None: continue pos = world_to_grid(Vec.from_str(str_pos)) # Exclude entities outside the main area - elevators mainly. # The border should never be set to air! if not ((0, 0, 0) <= pos <= (25, 25, 25)): continue # We need to manually set EmbeddedVoxel locations. # These might not be detected for items where there's a block # which is entirely empty - corridors and obs rooms for example. # We also need to check occupy locations, so that it can seed search # locs. item_id = get_item_id(ent) seeded = False if item_id: try: item = items[item_id.casefold()] except KeyError: pass else: orient = Matrix.from_angle(Angle.from_str(ent['angles'])) for local_pos in item.embed_voxels: # Offset down because 0 0 0 is the floor voxel. world_pos = (Vec(local_pos) - (0, 0, 1)) @ orient + pos self[round(world_pos, 0)] = Block.EMBED for occu in item.occupy_voxels: world_pos = Vec(occu.pos) @ orient + pos air_search_locs.append((round(world_pos, 0), False)) seeded = True if not seeded: # Assume origin is its location. air_search_locs.append((pos.copy(), False)) can_have_pit = bottomlessPit.pits_allowed() for brush in vmf.brushes[:]: tex = {face.mat.casefold() for face in brush.sides} bbox_min, bbox_max = brush.get_bbox() if ('nature/toxicslime_a2_bridge_intro' in tex or 'nature/toxicslime_puzzlemaker_cheap' in tex): # It's goo! x = bbox_min.x + 64 y = bbox_min.y + 64 g_x = x // 128 g_y = y // 128 is_pit = can_have_pit and bottomlessPit.is_pit( bbox_min, bbox_max) # If goo is multi-level, we want to record all pos! z_pos = range(int(bbox_min.z) + 64, int(bbox_max.z), 128) top_ind = len(z_pos) - 1 for ind, z in enumerate(z_pos): g_z = z // 128 self[g_x, g_y, g_z] = Block.from_pitgoo_attr( is_pit, is_top=(ind == top_ind), is_bottom=(ind == 0), ) # If goo has totally submerged tunnels, they are not filled. # Add each horizontal neighbour to the search list. # If not found they'll be ignored. goo_search_locs += [ (Vec(g_x - 1, g_y, g_z), True), (Vec(g_x + 1, g_y, g_z), True), (Vec(g_x, g_y + 1, g_z), True), (Vec(g_x, g_y - 1, g_z), True), ] # Remove the brush, since we're not going to use it. vmf.remove_brush(brush) # Indicate that this map contains goo/pits if is_pit: has_attr[VOICE_ATTR_PIT] = True else: has_attr[VOICE_ATTR_GOO] = True continue pos = world_to_grid(brush.get_origin(bbox_min, bbox_max)) if bbox_max - bbox_min == (128, 128, 128): # Full block.. self[pos] = Block.SOLID else: # Must be an embbedvoxel block self[pos] = Block.EMBED LOGGER.info('Analysed map, filling air... ({} starting positions..)', len(air_search_locs)) self.fill_air(goo_search_locs + air_search_locs) LOGGER.info('Air filled!')
def edit_panel(vmf: VMF, inst: Entity, props: Property, create: bool) -> None: """Implements SetPanelOptions and CreatePanel.""" orient = Matrix.from_angle(Angle.from_str(inst['angles'])) normal: Vec = round(props.vec('normal', 0, 0, 1) @ orient, 6) origin = Vec.from_str(inst['origin']) uaxis, vaxis = Vec.INV_AXIS[normal.axis()] points: Set[Tuple[float, float, float]] = set() if 'point' in props: for prop in props.find_all('point'): points.add( conditions.resolve_offset(inst, prop.value, zoff=-64).as_tuple()) elif 'pos1' in props and 'pos2' in props: pos1, pos2 = Vec.bbox( conditions.resolve_offset(inst, props['pos1', '-48 -48 0'], zoff=-64), conditions.resolve_offset(inst, props['pos2', '48 48 0'], zoff=-64), ) points.update(map(Vec.as_tuple, Vec.iter_grid(pos1, pos2, 32))) else: # Default to the full tile. points.update({(Vec(u, v, -64.0) @ orient + origin).as_tuple() for u in [-48.0, -16.0, 16.0, 48.0] for v in [-48.0, -16.0, 16.0, 48.0]}) tiles_to_uv: Dict[tiling.TileDef, Set[Tuple[int, int]]] = defaultdict(set) for pos in points: try: tile, u, v = tiling.find_tile(Vec(pos), normal, force=create) except KeyError: continue tiles_to_uv[tile].add((u, v)) if not tiles_to_uv: LOGGER.warning('"{}": No tiles found for panels!', inst['targetname']) return # If bevels is provided, parse out the overall world positions. bevel_world: Optional[Set[Tuple[int, int]]] try: bevel_prop = props.find_key('bevel') except NoKeyError: bevel_world = None else: bevel_world = set() if bevel_prop.has_children(): # Individually specifying offsets. for bevel_str in bevel_prop.as_array(): bevel_point = Vec.from_str(bevel_str) @ orient + origin bevel_world.add( (int(bevel_point[uaxis]), int(bevel_point[vaxis]))) elif srctools.conv_bool(bevel_prop.value): # Fill the bounding box. bbox_min, bbox_max = Vec.bbox(map(Vec, points)) off = Vec.with_axes(uaxis, 32, vaxis, 32) bbox_min -= off bbox_max += off for pos in Vec.iter_grid(bbox_min, bbox_max, 32): if pos.as_tuple() not in points: bevel_world.add((pos[uaxis], pos[vaxis])) # else: No bevels. panels: List[tiling.Panel] = [] for tile, uvs in tiles_to_uv.items(): if create: panel = tiling.Panel( None, inst, tiling.PanelType.NORMAL, thickness=4, bevels=(), ) panel.points = uvs tile.panels.append(panel) else: for panel in tile.panels: if panel.same_item(inst) and panel.points == uvs: break else: LOGGER.warning('No panel to modify found for "{}"!', inst['targetname']) continue panels.append(panel) pan_type = '<nothing?>' try: pan_type = conditions.resolve_value(inst, props['type']) panel.pan_type = tiling.PanelType(pan_type.lower()) except LookupError: pass except ValueError: raise ValueError('Unknown panel type "{}"!'.format(pan_type)) if 'thickness' in props: panel.thickness = srctools.conv_int( conditions.resolve_value(inst, props['thickness'])) if panel.thickness not in (2, 4, 8): raise ValueError( '"{}": Invalid panel thickess {}!\n' 'Must be 2, 4 or 8.', inst['targetname'], panel.thickness, ) if bevel_world is not None: panel.bevels.clear() for u, v in bevel_world: # Convert from world points to UV positions. u = (u - tile.pos[uaxis] + 48) / 32 v = (v - tile.pos[vaxis] + 48) / 32 # Cull outside here, we wont't use them. if -1 <= u <= 4 and -1 <= v <= 4: panel.bevels.add((u, v)) if 'offset' in props: panel.offset = conditions.resolve_offset(inst, props['offset']) panel.offset -= Vec.from_str(inst['origin']) if 'template' in props: # We only want the template inserted once. So remove it from all but one. if len(panels) == 1: panel.template = conditions.resolve_value( inst, props['template']) else: panel.template = '' if 'nodraw' in props: panel.nodraw = srctools.conv_bool( conditions.resolve_value(inst, props['nodraw'])) if 'seal' in props: panel.seal = srctools.conv_bool( conditions.resolve_value(inst, props['seal'])) if 'move_bullseye' in props: panel.steals_bullseye = srctools.conv_bool( conditions.resolve_value(inst, props['move_bullseye'])) if 'keys' in props or 'localkeys' in props: # First grab the existing ent, so we can edit it. # These should all have the same value, unless they were independently # edited with mismatching point sets. In that case destroy all those existing ones. existing_ents: Set[Optional[Entity]] = { panel.brush_ent for panel in panels } try: [brush_ent] = existing_ents except ValueError: LOGGER.warning( 'Multiple independent panels for "{}" were made, then the ' 'brush entity was edited as a group! Discarding ' 'individual ents...', inst['targetname']) for brush_ent in existing_ents: if brush_ent is not None and brush_ent in vmf.entities: brush_ent.remove() brush_ent = None if brush_ent is None: brush_ent = vmf.create_ent('') old_pos = brush_ent.keys.pop('origin', None) conditions.set_ent_keys(brush_ent, inst, props) if not brush_ent['classname']: if create: # This doesn't make sense, you could just omit the prop. LOGGER.warning( 'No classname provided for panel "{}"!', inst['targetname'], ) # Make it a world brush. brush_ent.remove() brush_ent = None else: # We want to do some post-processing. # Localise any origin value. if 'origin' in brush_ent.keys: pos = Vec.from_str(brush_ent['origin']) pos.localise( Vec.from_str(inst['origin']), Vec.from_str(inst['angles']), ) brush_ent['origin'] = pos elif old_pos is not None: brush_ent['origin'] = old_pos # If it's func_detail, clear out all the keys. # Particularly `origin`, but the others are useless too. if brush_ent['classname'] == 'func_detail': brush_ent.clear_keys() brush_ent['classname'] = 'func_detail' for panel in panels: panel.brush_ent = brush_ent
"""Generate models for all the different fizzler sizes.""" from typing import NamedTuple, List, Tuple, Dict from srctools.smd import Mesh, Bone, BoneFrame, Triangle, Vertex from srctools import VMF, Vec, Angle import os import subprocess from math import radians from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor # Name to use for the root bone. ROOT_NAME = 'root' CACHE = {} STYLES = [ ('clean', Angle(0, 0, 0), 'fizzler/fizz_effect_portal'), ('retro', Angle(0, 90, 0), 'fizzler/fizz_effect_retro_portal'), ] P2_LOC = os.environ.get('PORTAL_2_LOC') print('Portal 2: ', P2_LOC) class Shape: """A fizzler size, plus the block geo.""" def __init__(self, name: str, block: str, points: List[Tuple[Vec, Angle]]) -> None: self.name = name self.points = points self.block_fname = block + '.smd' def __repr__(self) -> str:
def group_props_ent( prop_groups: Dict[Optional[tuple], List[StaticProp]], rejected: List[StaticProp], get_model: Callable[[str], Tuple[Optional[QC], Optional[Model]]], bbox_ents: List[Entity], min_cluster: int, ) -> Iterator[List[StaticProp]]: """Given the groups of props, merge props according to the provided ents.""" # (name, skinset) -> list of boxes, constructed as 6 (pos, norm) tuples. combine_sets = defaultdict( list ) # type: Dict[Tuple[str, FrozenSet[str]], List[List[Tuple[Vec, Vec]]]] empty_fs = frozenset('') for ent in bbox_ents: # Either provided name, or unique value. name = ent['name'] or format(int(ent['hammerid']), 'X') origin = Vec.from_str(ent['origin']) skinset = empty_fs mdl_name = ent['prop'] if mdl_name: qc, mdl = get_model(mdl_name) if mdl is not None: skinset = frozenset({ tex.casefold().replace('\\', '/') for tex in mdl.iter_textures([conv_int(ent['skin'])]) }) # Compute 6 planes to use for collision detection. mat = Matrix.from_angle(Angle.from_str(ent['angles'])) mins, maxes = Vec.bbox( Vec.from_str(ent['mins']), Vec.from_str(ent['maxs']), ) # Enlarge slightly to ensure it never has a zero area. # Otherwise the normal could potentially be invalid. mins -= 0.05 maxes += 0.05 # For each direction, compute a position on the plane and # the normal vector. combine_sets[name, skinset].append([( origin + Vec.with_axes(axis, offset) @ mat, Vec.with_axes(axis, norm) @ mat, ) for offset, norm in zip([mins, maxes], (-1, 1)) for axis in ('x', 'y', 'z')]) # Each of these groups cannot be merged with other ones. for group_key, group in prop_groups.items(): if group_key is None: continue # No point merging single/empty groups. group_skinset = group_key[0] if len(group) < min_cluster: rejected.extend(group) group.clear() continue for (name, skinset), boxes in combine_sets.items(): if skinset and skinset != group_skinset: continue # No match found = defaultdict(list) # type: Dict[int, List[StaticProp]] for prop in list(group): for box in boxes: if bsp_collision(prop.origin, box): # Group by this box object's identity. # That's a cheap way to keep each propcombine set # grouped uniquely. found[id(boxes)].append(prop) break for subgroup in found.values(): actual = set(subgroup).intersection(group) if len(actual) >= min_cluster: yield list(actual) for prop in actual: group.remove(prop) # Finally, reject all the ones not in a bbox. for group in prop_groups.values(): rejected.extend(group)
def _parse_value(value: str) -> Angle: return Angle.from_str(value)
def _init_orient(self) -> Matrix: """We need to rotate the orient, because items have forward as negative X.""" rot = Matrix.from_angle(Angle.from_str(self.ent['angles'])) return Matrix.from_yaw(180) @ rot
def comp_prop_rope(ctx: Context) -> None: """Build static props for ropes.""" compiler = ModelCompiler.from_ctx(ctx, 'ropes') # id -> node. all_nodes: MutableMapping[NodeID, NodeEnt] = {} # Given a targetname, all the nodes with that name. name_to_nodes: MutableMapping[str, List[NodeEnt]] = defaultdict(list) # Group name -> nodes with that group. group_to_node: Dict[str, List[NodeEnt]] = defaultdict(list) # Store the node/next-key pairs for linking after they're all parsed. temp_conns: List[Tuple[NodeEnt, str]] = [] for ent in ctx.vmf.by_class['comp_prop_rope'] | ctx.vmf.by_class[ 'comp_prop_cable']: ent.remove() conf = Config.parse(ent) node = NodeEnt( Vec.from_str(ent['origin']), conf, NodeID(ent['hammerid']), ent['group'].casefold(), ) all_nodes[node.id] = node if node.group: group_to_node[node.group].append(node) if ent['targetname']: name_to_nodes[ent['targetname'].casefold()].append(node) if ent['nextkey']: temp_conns.append((node, ent['nextkey'].casefold())) if not all_nodes: return LOGGER.info('{} rope nodes found.', len(all_nodes)) connections_to: Dict[NodeID, List[NodeEnt]] = defaultdict(list) connections_from: Dict[NodeID, List[NodeEnt]] = defaultdict(list) for node, target in temp_conns: found: List[NodeEnt] = [] if target.endswith('*'): search = target[:-1] for name, nodes in name_to_nodes.items(): if name.startswith(search): found.extend(nodes) else: found.extend(name_to_nodes.get(target, ())) found.sort() for dest in found: connections_from[node.id].append(dest) connections_to[dest.id].append(node) static_props = list(ctx.bsp.static_props()) vis_tree_top = ctx.bsp.vis_tree() # To group nodes, take each group out, then search recursively through # all connections from it to other nodes. todo = set(all_nodes.values()) with compiler: while todo: node = todo.pop() connections: Set[Tuple[NodeID, NodeID]] = set() # We need the set for fast is-in checks, and the list # so we can loop through while modifying it. nodes: Set[NodeEnt] = {node} unchecked: List[NodeEnt] = [node] while unchecked: node = unchecked.pop() # Three links to others - connections to/from, and groups. # We'll only ever follow a path once, so pop from the dicts. if node.group: for subnode in group_to_node.pop(node.group, ()): if subnode not in nodes: nodes.add(subnode) unchecked.append(subnode) for conn_node in connections_from.pop(node.id, ()): connections.add((node.id, conn_node.id)) if conn_node not in nodes: nodes.add(conn_node) unchecked.append(conn_node) for conn_node in connections_to.pop(node.id, ()): connections.add((conn_node.id, node.id)) if conn_node not in nodes: nodes.add(conn_node) unchecked.append(conn_node) todo -= nodes if len(nodes) == 1: LOGGER.warning( 'Node at {} has no connections to it! Skipping.', node.pos) continue bbox_min, bbox_max = Vec.bbox(node.pos for node in nodes) center = (bbox_min + bbox_max) / 2 node = None for node in nodes: node.pos -= center model_name, coll_data = compiler.get_model( (frozenset(nodes), frozenset(connections)), build_rope, center, ) # Use the node closest to the center. That way # it shouldn't be inside walls, and be about representative of # the whole model. light_origin = min( (point for point1, radius1, point2, radius2 in coll_data for point in [point1, point2]), key=lambda pos: (pos - center).mag_sq()) # Compute the flags. Just pick a random node, from above. conf = node.config flags = StaticPropFlags.NONE if conf.prop_light_bounce: flags |= StaticPropFlags.BOUNCED_LIGHTING if conf.prop_no_shadows: flags |= StaticPropFlags.NO_SHADOW if conf.prop_no_vert_light: flags |= StaticPropFlags.NO_PER_VERTEX_LIGHTING if conf.prop_no_self_shadow: flags |= StaticPropFlags.NO_SELF_SHADOWING static_props.append( StaticProp( model=model_name, origin=center, angles=Angle(0, 270, 0), scaling=1.0, visleafs=compute_visleafs(coll_data, vis_tree_top), solidity=0, flags=flags, tint=Vec(conf.prop_rendercolor), renderfx=conf.prop_renderalpha, lighting_origin=light_origin, min_fade=conf.prop_fade_min_dist, max_fade=conf.prop_fade_max_dist, fade_scale=conf.prop_fade_scale, )) LOGGER.info('Built {} models.', len(all_nodes)) ctx.bsp.write_static_props(static_props)
def res_reshape_fizzler(vmf: VMF, shape_inst: Entity, res: Property): """Convert a fizzler connected via the output to a new shape. This allows for different placing of fizzler items. * Each `segment` parameter should be a `x y z;x y z` pair of positions that represent the ends of the fizzler. * `up_axis` should be set to a normal vector pointing in the new 'upward' direction. * If none are connected, a regular fizzler will be synthesized. The following fixup vars will be set to allow the shape to match the fizzler: * `$uses_nodraw` will be 1 if the fizzler nodraws surfaces behind it. """ shape_name = shape_inst['targetname'] shape_item = connections.ITEMS.pop(shape_name) shape_orient = Matrix.from_angle(Angle.from_str(shape_inst['angles'])) up_axis: Vec = round(res.vec('up_axis') @ shape_orient, 6) for conn in shape_item.outputs: fizz_item = conn.to_item try: fizz = fizzler.FIZZLERS[fizz_item.name] except KeyError: continue # Detach this connection and remove traces of it. conn.remove() fizz.emitters.clear() # Remove old positions. fizz.up_axis = up_axis fizz.base_inst['origin'] = shape_inst['origin'] fizz.base_inst['angles'] = shape_inst['angles'] break else: # No fizzler, so generate a default. # We create the fizzler instance, Fizzler object, and Item object # matching it. # This is hardcoded to use regular Emancipation Fields. base_inst = vmf.create_ent( targetname=shape_name, classname='func_instance', origin=shape_inst['origin'], angles=shape_inst['angles'], file=resolve_one('<ITEM_BARRIER_HAZARD:fizz_base>'), ) base_inst.fixup.update(shape_inst.fixup) fizz = fizzler.FIZZLERS[shape_name] = fizzler.Fizzler( fizzler.FIZZ_TYPES['VALVE_MATERIAL_EMANCIPATION_GRID'], up_axis, base_inst, [], ) fizz_item = connections.Item( base_inst, connections.ITEM_TYPES['item_barrier_hazard'], ant_floor_style=shape_item.ant_floor_style, ant_wall_style=shape_item.ant_wall_style, ) connections.ITEMS[shape_name] = fizz_item # Transfer the input/outputs from us to the fizzler. for inp in list(shape_item.inputs): inp.to_item = fizz_item for conn in list(shape_item.outputs): conn.from_item = fizz_item # If the fizzler has no outputs, then strip out antlines. Otherwise, # they need to be transferred across, so we can't tell safely. if fizz_item.output_act() is None and fizz_item.output_deact() is None: shape_item.delete_antlines() else: shape_item.transfer_antlines(fizz_item) fizz_base = fizz.base_inst fizz_base['origin'] = shape_inst['origin'] origin = Vec.from_str(shape_inst['origin']) fizz.has_cust_position = True # Since the fizzler is moved elsewhere, it's the responsibility of # the new item to have holes. fizz.embedded = False # So tell it whether or not it needs to do so. shape_inst.fixup['$uses_nodraw'] = fizz.fizz_type.nodraw_behind for seg_prop in res.find_all('Segment'): vec1, vec2 = seg_prop.value.split(';') seg_min_max = Vec.bbox( Vec.from_str(vec1) @ shape_orient + origin, Vec.from_str(vec2) @ shape_orient + origin, ) fizz.emitters.append(seg_min_max)
def add_point(self, pos: Vec) -> None: """Add the given point to the end of the animation.""" self.mesh.animation[self.cur_frame] = [ BoneFrame(self.move_bone, pos, Angle(next(self.rotator))) ] self.cur_frame += 1
def res_signage(vmf: VMF, inst: Entity, res: Property): """Implement the Signage item.""" sign: Optional[Sign] try: sign = (CONN_SIGNAGES if res.bool('connection') else SIGNAGES)[inst.fixup[consts.FixupVars.TIM_DELAY]] except KeyError: # Blank sign sign = None has_arrow = inst.fixup.bool(consts.FixupVars.ST_ENABLED) make_4x4 = res.bool('set4x4tile') sign_prim: Optional[Sign] sign_sec: Optional[Sign] if has_arrow: sign_prim = sign sign_sec = SIGNAGES['arrow'] elif sign is not None: sign_prim = sign.primary or sign sign_sec = sign.secondary or None else: # Neither sign or arrow, delete this. inst.remove() return origin = Vec.from_str(inst['origin']) orient = Matrix.from_angle(Angle.from_str(inst['angles'])) normal = -orient.up() forward = -orient.forward() prim_pos = Vec(0, -16, -64) @ orient + origin sec_pos = Vec(0, +16, -64) @ orient + origin template_id = res['template_id', ''] if inst.fixup.bool(consts.FixupVars.ST_REVERSED): # Flip around. forward = -forward prim_visgroup = 'secondary' sec_visgroup = 'primary' prim_pos, sec_pos = sec_pos, prim_pos else: prim_visgroup = 'primary' sec_visgroup = 'secondary' if sign_prim and sign_sec: inst['file'] = fname = res['large_clip', ''] inst['origin'] = (prim_pos + sec_pos) / 2 else: inst['file'] = fname = res['small_clip', ''] inst['origin'] = prim_pos if sign_prim else sec_pos conditions.ALL_INST.add(fname.casefold()) brush_faces: List[Side] = [] tiledef: Optional[tiling.TileDef] = None if template_id: if sign_prim and sign_sec: visgroup = [prim_visgroup, sec_visgroup] elif sign_prim: visgroup = [prim_visgroup] else: visgroup = [sec_visgroup] template = template_brush.import_template( vmf, template_id, origin, orient, force_type=template_brush.TEMP_TYPES.detail, additional_visgroups=visgroup, ) for face in template.detail.sides(): if face.normal() == normal: brush_faces.append(face) else: # Direct on the surface. # Find the grid pos first. grid_pos = (origin // 128) * 128 + 64 try: tiledef = tiling.TILES[(grid_pos + 128 * normal).as_tuple(), (-normal).as_tuple()] except KeyError: LOGGER.warning( "Can't place signage at ({}) in ({}) direction!", origin, normal, exc_info=True, ) return if sign_prim is not None: over = place_sign( vmf, brush_faces, sign_prim, prim_pos, normal, forward, rotate=True, ) if tiledef is not None: tiledef.bind_overlay(over) if make_4x4: try: tile, u, v = tiling.find_tile(prim_pos, -normal) except KeyError: pass else: tile[u, v] = tile[u, v].as_4x4 if sign_sec is not None: if has_arrow and res.bool('arrowDown'): # Arrow texture points down, need to flip it. forward = -forward over = place_sign( vmf, brush_faces, sign_sec, sec_pos, normal, forward, rotate=not has_arrow, ) if tiledef is not None: tiledef.bind_overlay(over) if make_4x4: try: tile, u, v = tiling.find_tile(sec_pos, -normal) except KeyError: pass else: tile[u, v] = tile[u, v].as_4x4
def generate(style: str, sty_ang: Angle, field_tex: str, shape: Shape) -> str: """Generate a specific model.""" # Load the models we use. emitter = load_model(style + '_fizz_ref.smd') anim_open = load_model(style + '_explode_out.smd') anim_close = load_model(style + '_explode_in.smd') ref = Mesh.blank(ROOT_NAME) [root] = ref.bones.values() # First, copy in the static block geo. try: ref.append_model(load_model(shape.block_fname)) except FileNotFoundError: print('No blocks for: ', shape.block_fname) # Rename the fizzler field material. for tri in ref.triangles: if tri.mat == 'field': tri.mat = field_tex new_bones: Dict[Tuple[float, float, float], Dict[Bone, Bone]] = {} ind = 1 # Generate the duplicate sets of bones. for pos, angles in shape.points: angles = sty_ang @ angles if angles.as_tuple() in new_bones: continue bones: Dict[Bone, Bone] bones = new_bones[angles.as_tuple()] = { bone: Bone(f'{bone.name}_{ind}', None) for bone in emitter.bones.values() } local_root = bones[root] = Bone(f'{ROOT_NAME}_{ind}', root) ind += 1 # Now they're all created, fix up the parents. for bone in emitter.bones.values(): if bone.parent is not None: bones[bone].parent = bones[bone.parent] else: bones[bone].parent = local_root # Add the bones to the reference. for bone in bones.values(): ref.bones[bone.name] = bone # And copy over their start pose. ref.animation[0].append(BoneFrame(local_root, Vec(), Angle())) for frame in emitter.animation[0]: ref.animation[0].append( BoneFrame(bones[frame.bone], frame.position, frame.rotation)) # Now, place each emitter. for pos, angles in shape.points: angles = sty_ang @ angles bones = new_bones[angles.as_tuple()] for tri in emitter.triangles: ref.triangles.append( Triangle( tri.mat, *[ Vertex((vert.pos @ sty_ang) + pos, vert.norm, vert.tex_u, vert.tex_v, [(bones[old], fact) for old, fact in vert.links]) for vert in tri ])) folder = f'generated/{style}/' make_anim(root, sty_ang, new_bones, f'{folder}{shape.name}_open', anim_open.animation) make_anim(root, sty_ang, new_bones, f'{folder}{shape.name}_close', anim_close.animation) make_anim(root, sty_ang, new_bones, f'{folder}{shape.name}_idle', {0: anim_open.animation[0]}) print(f' - {folder}{shape.name}_ref...', flush=True) with open(f'{folder}{shape.name}_ref.smd', 'wb') as f: ref.export(f) qc_file = f'{folder}{shape.name}.qc' with open(qc_file, 'w') as f: f.write( f'$modelname "props_map_editor/BEE2/{style}/{shape.name}.mdl"\n') f.write('$BodyGroup "Body" {\n\tstudio "%_ref.smd"\n}\n'.replace( '%', shape.name)) f.write( '$cdmaterials "models/props_map_editor/" "BEE2/models/props_map_editor/"\n' ) f.write(''' $sequence "idle" "%_idle.smd" $sequence "explodeOut" "%_open.smd" $sequence "explodeIn" "%_close.smd" '''.replace('%', shape.name)) return qc_file
def compile_func( lookup_model: Callable[[str], Tuple[QC, Model]], mdl_key: Tuple[Set[PropPos], bool], temp_folder: Path, mdl_name: str, ) -> None: """Build this merged model.""" LOGGER.info('Compiling {}...', mdl_name) prop_pos, has_coll = mdl_key # Unify these properties. surfprops = set() # type: Set[str] cdmats = set() # type: Set[str] contents = set() # type: Set[int] for prop in prop_pos: qc, mdl = lookup_model(prop.model) assert mdl is not None, prop.model surfprops.add(mdl.surfaceprop.casefold()) cdmats.update(mdl.cdmaterials) contents.add(mdl.contents) if len(surfprops) > 1: raise ValueError('Multiple surfaceprops? Should be filtered out.') if len(contents) > 1: raise ValueError('Multiple contents? Should be filtered out.') [surfprop] = surfprops [phy_content_type] = contents ref_mesh = Mesh.blank('static_prop') coll_mesh = None # type: Optional[Mesh] for prop in prop_pos: qc, mdl = lookup_model(prop.model) try: child_ref = _mesh_cache[qc, prop.skin] except KeyError: LOGGER.info('Parsing ref "{}"', qc.ref_smd) with open(qc.ref_smd, 'rb') as fb: child_ref = Mesh.parse_smd(fb) if prop.skin != 0 and prop.skin < len(mdl.skins): # We need to rename the materials to match the skin. swap_skins = dict(zip(mdl.skins[0], mdl.skins[prop.skin])) for tri in child_ref.triangles: tri.mat = swap_skins.get(tri.mat, tri.mat) # For some reason all the SMDs are rotated badly, but only # if we append them. rot = Matrix.from_yaw(90) for tri in child_ref.triangles: for vert in tri: vert.pos @= rot vert.norm @= rot _mesh_cache[qc, prop.skin] = child_ref child_coll = build_collision(qc, prop, child_ref) offset = Vec(prop.x, prop.y, prop.z) angles = Angle(prop.pit, prop.yaw, prop.rol) ref_mesh.append_model(child_ref, angles, offset, prop.scale * qc.ref_scale) if has_coll and child_coll is not None: if coll_mesh is None: coll_mesh = Mesh.blank('static_prop') coll_mesh.append_model(child_coll, angles, offset, prop.scale * qc.phy_scale) with (temp_folder / 'reference.smd').open('wb') as fb: ref_mesh.export(fb) # Generate a blank animation. with (temp_folder / 'anim.smd').open('wb') as fb: Mesh.blank('static_prop').export(fb) if coll_mesh is not None: with (temp_folder / 'physics.smd').open('wb') as fb: coll_mesh.export(fb) with (temp_folder / 'model.qc').open('w') as f: f.write( QC_TEMPLATE.format( path=mdl_name, surf=surfprop, # For $contents, we need to decompose out each bit. # This is the same as BSP's flags in public/bsp_flags.h # However only a few types are allowable. contents=' '.join([ cont for mask, cont in [ (0x1, '"solid"'), (0x8, '"grate"'), (0x2000000, '"monster"'), (0x20000000, '"ladder"'), ] if mask & phy_content_type # 0 needs to produce this value. ]) or '"notsolid"', )) for mat in sorted(cdmats): f.write('$cdmaterials "{}"\n'.format(mat)) if coll_mesh is not None: f.write(QC_COLL_TEMPLATE)
def static_props(self) -> Iterator['StaticProp']: """Read in the Static Props lump.""" # The version of the static prop format - different features. try: version = self.game_lumps[b'sprp'].version except KeyError: raise ValueError('No static prop lump!') from None if version > 11: raise ValueError('Unknown version ({})!'.format(version)) if version < 4: # Predates HL2... raise ValueError('Static prop version {} is too old!') static_lump = BytesIO(self.game_lumps[b'sprp'].data) # Array of model filenames. model_dict = list(self._read_static_props_models(static_lump)) [visleaf_count] = struct_read('<i', static_lump) visleaf_list = list(struct_read('H' * visleaf_count, static_lump)) [prop_count] = struct_read('<i', static_lump) for i in range(prop_count): origin = Vec(struct_read('fff', static_lump)) angles = Angle(struct_read('fff', static_lump)) [model_ind] = struct_read('<H', static_lump) ( first_leaf, leaf_count, solidity, flags, skin, min_fade, max_fade, ) = struct_read('<HHBBiff', static_lump) model_name = model_dict[model_ind] visleafs = visleaf_list[first_leaf:first_leaf + leaf_count] lighting_origin = Vec(struct_read('<fff', static_lump)) if version >= 5: fade_scale = struct_read('<f', static_lump)[0] else: fade_scale = 1 # default if version in (6, 7): min_dx_level, max_dx_level = struct_read('<HH', static_lump) else: # Replaced by GPU & CPU in later versions. min_dx_level = max_dx_level = 0 # None if version >= 8: ( min_cpu_level, max_cpu_level, min_gpu_level, max_gpu_level, ) = struct_read('BBBB', static_lump) else: # None min_cpu_level = max_cpu_level = 0 min_gpu_level = max_gpu_level = 0 if version >= 7: r, g, b, renderfx = struct_read('BBBB', static_lump) # Alpha isn't used. tint = Vec(r, g, b) else: # No tint. tint = Vec(255, 255, 255) renderfx = 255 if version >= 11: # Unknown data, though it's float-like. unknown_1 = struct_read('<i', static_lump) if version >= 10: # Extra flags, post-CSGO. flags |= struct_read('<I', static_lump)[0] << 8 flags = StaticPropFlags(flags) scaling = 1.0 disable_on_xbox = False if version >= 11: # XBox support was removed. Instead this is the scaling factor. [scaling] = struct_read("<f", static_lump) elif version >= 9: # The single boolean byte also produces 3 pad bytes. [disable_on_xbox] = struct_read('<?xxx', static_lump) yield StaticProp( model_name, origin, angles, scaling, visleafs, solidity, flags, skin, min_fade, max_fade, lighting_origin, fade_scale, min_dx_level, max_dx_level, min_cpu_level, max_cpu_level, min_gpu_level, max_gpu_level, tint, renderfx, disable_on_xbox, )
def test_construction(py_c_vec): """Check various parts of the constructor - Vec(), Vec.from_str().""" Vec, Angle, Matrix, parse_vec_str = py_c_vec for pit, yaw, rol in iter_vec(VALID_ZERONUMS): assert_ang(Angle(pit, yaw, rol), pit, yaw, rol) assert_ang(Angle(pit, yaw), pit, yaw, 0) assert_ang(Angle(pit), pit, 0, 0) assert_ang(Angle(), 0, 0, 0) assert_ang(Angle([pit, yaw, rol]), pit, yaw, rol) assert_ang(Angle([pit, yaw], roll=rol), pit, yaw, rol) assert_ang(Angle([pit], yaw=yaw, roll=rol), pit, yaw, rol) assert_ang(Angle([pit]), pit, 0, 0) assert_ang(Angle([pit, yaw]), pit, yaw, 0) assert_ang(Angle([pit, yaw, rol]), pit, yaw, rol) # Test this does nothing (except copy). ang = Angle(pit, yaw, rol) ang2 = Angle(ang) assert_ang(ang2, pit, yaw, rol) assert ang is not ang2 ang3 = Angle.copy(ang) assert_ang(ang3, pit, yaw, rol) assert ang is not ang3 # Test Angle.from_str() assert_ang(Angle.from_str('{} {} {}'.format(pit, yaw, rol)), pit, yaw, rol) assert_ang(Angle.from_str('<{} {} {}>'.format(pit, yaw, rol)), pit, yaw, rol) # {x y z} assert_ang(Angle.from_str('{{{} {} {}}}'.format(pit, yaw, rol)), pit, yaw, rol) assert_ang(Angle.from_str('({} {} {})'.format(pit, yaw, rol)), pit, yaw, rol) assert_ang(Angle.from_str('[{} {} {}]'.format(pit, yaw, rol)), pit, yaw, rol) # Test converting a converted Angle orig = Angle(pit, yaw, rol) new = Angle.from_str(Angle(pit, yaw, rol)) assert_ang(new, pit, yaw, rol) assert orig is not new # It must be a copy # Check as_tuple() makes an equivalent tuple tup = orig.as_tuple() # Flip to work arond the coercion. pit %= 360.0 yaw %= 360.0 rol %= 360.0 assert isinstance(tup, tuple) assert (pit, yaw, rol) == tup assert hash((pit, yaw, rol)) == hash(tup) # Bypass subclass functions. assert tuple.__getitem__(tup, 0) == pit assert tuple.__getitem__(tup, 1) == yaw assert tuple.__getitem__(tup, 2) == rol # Check failures in Angle.from_str() # Note - does not pass through unchanged, they're converted to floats! for val in VALID_ZERONUMS: test_val = val % 360.0 assert test_val == Angle.from_str('', pitch=val).pitch assert test_val == Angle.from_str('blah 4 2', yaw=val).yaw assert test_val == Angle.from_str('2 hi 2', pitch=val).pitch assert test_val == Angle.from_str('2 6 gh', roll=val).roll assert test_val == Angle.from_str('1.2 3.4', pitch=val).pitch assert test_val == Angle.from_str('34.5 38.4 -23 -38', roll=val).roll
def flag_check_marker(inst: Entity, flag: Property) -> bool: """Check if markers are present at a position. Parameters: * `name`: The name to look for. This can contain one `*` to match prefixes/suffixes. * `nameVar`: If found, set this variable to the actual name. * `pos`: The position to check. * `pos2`: If specified, the position is a bounding box from 1 to 2. * `radius`: Check markers within this distance. If this is specified, `pos2` is not permitted. * `global`: If true, positions are an absolute position, ignoring this instance. * `removeFound`: If true, remove the found marker. If you don't need it, this will improve performance. * `copyto`: Copies fixup vars from the searching instance to the one which set the marker. The value is in the form `$src $dest`. * `copyfrom`: Copies fixup vars from the one that set the marker to the searching instance. The value is in the form `$src $dest`. """ origin = Vec.from_str(inst['origin']) orient = Matrix.from_angle(Angle.from_str(inst['angles'])) name = inst.fixup.substitute(flag['name']).casefold() if '*' in name: try: prefix, suffix = name.split('*') except ValueError: raise ValueError(f'Name "{name}" must only have 1 *!') def match(val: str) -> bool: """Match a prefix or suffix.""" val = val.casefold() return val.startswith(prefix) and val.endswith(suffix) else: def match(val: str) -> bool: """Match an exact name.""" return val.casefold() == name try: is_global = srctools.conv_bool( inst.fixup.substitute(flag['global'], allow_invert=True)) except LookupError: is_global = False pos = Vec.from_str(inst.fixup.substitute(flag['pos'])) if not is_global: pos = pos @ orient + origin radius: float | None if 'pos2' in flag: if 'radius' in flag: raise ValueError('Only one of pos2 or radius must be defined.') pos2 = Vec.from_str(inst.fixup.substitute(flag['pos2'])) if not is_global: pos2 = pos2 @ orient + origin bb_min, bb_max = Vec.bbox(pos, pos2) radius = None LOGGER.debug('Searching for marker "{}" from ({})-({})', name, bb_min, bb_max) elif 'radius' in flag: radius = abs(srctools.conv_float(inst.fixup.substitute( flag['radius']))) bb_min = pos - (radius + 1.0) bb_max = pos + (radius + 1.0) LOGGER.debug('Searching for marker "{}" at ({}), radius={}', name, pos, radius) else: bb_min = pos - (1.0, 1.0, 1.0) bb_max = pos + (1.0, 1.0, 1.0) radius = 1e-6 LOGGER.debug('Searching for marker "{}" at ({})', name, pos) for i, marker in enumerate(MARKERS): if not marker.pos.in_bbox(bb_min, bb_max): continue if radius is not None and (marker.pos - pos).mag() > radius: continue if not match(marker.name): continue # Matched. if 'nameVar' in flag: inst.fixup[flag['namevar']] = marker.name if srctools.conv_bool( inst.fixup.substitute(flag['removeFound'], allow_invert=True)): LOGGER.debug('Removing found marker {}', marker) del MARKERS[i] for prop in flag.find_all('copyto'): src, dest = prop.value.split(' ', 1) marker.inst.fixup[dest] = inst.fixup[src] for prop in flag.find_all('copyfrom'): src, dest = prop.value.split(' ', 1) inst.fixup[dest] = marker.inst.fixup[src] return True return False
def res_add_overlay_inst(vmf: VMF, inst: Entity, res: Property) -> Optional[Entity]: """Add another instance on top of this one. If a single value, this sets only the filename. Values: - `file`: The filename. - `fixup_style`: The Fixup style for the instance. '0' (default) is Prefix, '1' is Suffix, and '2' is None. - `copy_fixup`: If true, all the `$replace` values from the original instance will be copied over. - `move_outputs`: If true, outputs will be moved to this instance. - `offset`: The offset (relative to the base) that the instance will be placed. Can be set to `<piston_top>` and `<piston_bottom>` to offset based on the configuration. `<piston_start>` will set it to the starting position, and `<piston_end>` will set it to the ending position of the Piston Platform's handles. - `rotation`: Rotate the instance by this amount. - `angles`: If set, overrides `rotation` and the instance angles entirely. - `fixup`/`localfixup`: Keyvalues in this block will be copied to the overlay entity. - If the value starts with `$`, the variable will be copied over. - If this is present, `copy_fixup` will be disabled. """ if not res.has_children(): # Use all the defaults. res = Property('AddOverlay', [ Property('File', res.value) ]) if 'angles' in res: angles = Angle.from_str(res['angles']) if 'rotation' in res: LOGGER.warning('"angles" option overrides "rotation"!') else: angles = Angle.from_str(res['rotation', '0 0 0']) angles @= Angle.from_str(inst['angles', '0 0 0']) orig_name = conditions.resolve_value(inst, res['file', '']) filename = instanceLocs.resolve_one(orig_name) if not filename: if not res.bool('silentLookup'): LOGGER.warning('Bad filename for "{}" when adding overlay!', orig_name) # Don't bother making a overlay which will be deleted. return None overlay_inst = conditions.add_inst( vmf, targetname=inst['targetname', ''], file=filename, angles=angles, origin=inst['origin'], fixup_style=res.int('fixup_style'), ) # Don't run if the fixup block exists.. if srctools.conv_bool(res['copy_fixup', '1']): if 'fixup' not in res and 'localfixup' not in res: # Copy the fixup values across from the original instance for fixup, value in inst.fixup.items(): overlay_inst.fixup[fixup] = value conditions.set_ent_keys(overlay_inst.fixup, inst, res, 'fixup') if res.bool('move_outputs', False): overlay_inst.outputs = inst.outputs inst.outputs = [] if 'offset' in res: overlay_inst['origin'] = conditions.resolve_offset(inst, res['offset']) return overlay_inst
def add_timer_relay(item: Item, has_sounds: bool) -> None: """Make a relay to play timer sounds, or fire once the outputs are done.""" assert item.timer is not None rl_name = item.name + '_timer_rl' relay = item.inst.map.create_ent( 'logic_relay', targetname=rl_name, startDisabled=0, spawnflags=0, ) if item.config.timer_sound_pos: relay_loc = item.config.timer_sound_pos.copy() relay_loc.localise( Vec.from_str(item.inst['origin']), Angle.from_str(item.inst['angles']), ) relay['origin'] = relay_loc else: relay['origin'] = item.inst['origin'] for cmd in item.config.timer_done_cmd: if cmd: relay.add_out(Output( 'OnTrigger', conditions.local_name(item.inst, cmd.target) or item.inst, conditions.resolve_value(item.inst, cmd.input), conditions.resolve_value(item.inst, cmd.params), inst_in=cmd.inst_in, delay=item.timer + cmd.delay, times=cmd.times, )) if item.config.timer_sound_pos is not None and has_sounds: timer_sound = options.get(str, 'timer_sound') timer_cc = options.get(str, 'timer_sound_cc') # The default sound has 'ticking' closed captions. # So reuse that if the style doesn't specify a different noise. # If explicitly set to '', we don't use this at all! if timer_cc is None and timer_sound != 'Portal.room1_TickTock': timer_cc = 'Portal.room1_TickTock' if timer_cc: timer_cc = 'cc_emit ' + timer_cc # Write out the VScript code to precache the sound, and play it on # demand. relay['vscript_init_code'] = ( 'function Precache() {' f'self.PrecacheSoundScript(`{timer_sound}`)' '}' ) relay['vscript_init_code2'] = ( 'function snd() {' f'self.EmitSound(`{timer_sound}`)' '}' ) packing.pack_files(item.inst.map, timer_sound, file_type='sound') for delay in range(item.timer): relay.add_out(Output( 'OnTrigger', '!self', 'CallScriptFunction', 'snd', delay=delay, )) if timer_cc: relay.add_out(Output( 'OnTrigger', '@command', 'Command', timer_cc, delay=delay, )) for outputs, cmd in [ (item.timer_output_start(), 'Trigger'), (item.timer_output_stop(), 'CancelPending') ]: for output in outputs: item.add_io_command(output, rl_name, cmd)
def res_set_texture(inst: Entity, res: Property): """Set the tile at a particular place to use a specific texture. This can only be set for an entire voxel side at once. `pos` is the position, relative to the instance (0 0 0 is the floor-surface). `dir` is the normal of the texture (pointing out) If `gridPos` is true, the position will be snapped so it aligns with the 128 brushes (Useful with fizzler/light strip items). `tex` is the texture to use. If `template` is set, the template should be an axis aligned cube. This will be rotated by the instance angles, and then the face with the same orientation will be applied to the face (with the rotation and texture). """ angles = Angle.from_str(inst['angles']) origin = Vec.from_str(inst['origin']) pos = Vec.from_str(res['pos', '0 0 0']) pos.z -= 64 # Subtract so origin is the floor-position pos.localise(origin, angles) norm = round(Vec.from_str(res['dir', '0 0 1']) @ angles, 6) if srctools.conv_bool(res['gridpos', '0']): for axis in 'xyz': # Don't realign things in the normal's axis - # those are already fine. if not norm[axis]: pos[axis] //= 128 pos[axis] *= 128 pos[axis] += 64 try: # The user expects the tile to be at it's surface pos, not the # position of the voxel. tile = tiling.TILES[(pos - 64 * norm).as_tuple(), norm.as_tuple()] except KeyError: LOGGER.warning( '"{}": Could not find tile at {} with orient {}!', inst['targetname'], pos, norm, ) return temp_id = inst.fixup.substitute(res['template', '']) if temp_id: temp = template_brush.get_scaling_template(temp_id).rotate( angles, origin) else: temp = template_brush.ScalingTemplate.world() tex = inst.fixup.substitute(res['tex', '']) if tex.startswith('<') and tex.endswith('>'): LOGGER.warning( 'Special <lookups> for AlterTexture are ' 'no longer usable! ("{}")', tex) elif tex.startswith('[') and tex.endswith(']'): gen, name = texturing.parse_name(tex[1:-1]) tex = gen.get(pos - 64 * norm, name) tile.override = (tex, temp)
def make_bottomless_pit(vmf: VMF, max_height): """Generate bottomless pits.""" import vbsp tele_ref = SETTINGS['tele_ref'] tele_dest = SETTINGS['tele_dest'] use_skybox = bool(SETTINGS['skybox']) if use_skybox: tele_off = Vec( x=SETTINGS['off_x'], y=SETTINGS['off_y'], ) else: tele_off = Vec(0, 0, 0) # Controlled by the style, not skybox! blend_light = options.get(str, 'pit_blend_light') if use_skybox: # Add in the actual skybox edges and triggers. conditions.add_inst( vmf, file=SETTINGS['skybox'], targetname='skybox', origin=tele_off, ) fog_opt = vbsp.settings['fog'] # Now generate the sky_camera, with appropriate values. sky_camera = vmf.create_ent( classname='sky_camera', scale='1.0', origin=tele_off, angles=fog_opt['direction'], fogdir=fog_opt['direction'], fogcolor=fog_opt['primary'], fogstart=fog_opt['start'], fogend=fog_opt['end'], fogenable='1', heightFogStart=fog_opt['height_start'], heightFogDensity=fog_opt['height_density'], heightFogMaxDensity=fog_opt['height_max_density'], ) if fog_opt['secondary']: # Only enable fog blending if a secondary color is enabled sky_camera['fogblend'] = '1' sky_camera['fogcolor2'] = fog_opt['secondary'] sky_camera['use_angles'] = '1' else: sky_camera['fogblend'] = '0' sky_camera['use_angles'] = '0' if SETTINGS['skybox_ceil'] != '': # We dynamically add the ceiling so it resizes to match the map, # and lighting won't be too far away. conditions.add_inst( vmf, file=SETTINGS['skybox_ceil'], targetname='skybox', origin=tele_off + (0, 0, max_height), ) if SETTINGS['targ'] != '': # Add in the teleport reference target. conditions.add_inst( vmf, file=SETTINGS['targ'], targetname='skybox', origin='0 0 0', ) # First, remove all of Valve's triggers inside pits. for trig in vmf.by_class['trigger_multiple'] | vmf.by_class['trigger_hurt']: if brushLoc.POS['world':Vec.from_str(trig['origin'])].is_pit: trig.remove() # Potential locations of bordering brushes.. wall_pos = set() side_dirs = [ (0, -128, 0), # N (0, +128, 0), # S (-128, 0, 0), # E (+128, 0, 0) # W ] # Only use 1 entity for the teleport triggers. If multiple are used, # cubes can contact two at once and get teleported odd places. tele_trig = None hurt_trig = None for grid_pos, block_type in brushLoc.POS.items( ): # type: Vec, brushLoc.Block pos = brushLoc.grid_to_world(grid_pos) if not block_type.is_pit: continue # Physics objects teleport when they hit the bottom of a pit. if block_type.is_bottom and use_skybox: if tele_trig is None: tele_trig = vmf.create_ent( classname='trigger_teleport', spawnflags='4106', # Physics and npcs landmark=tele_ref, target=tele_dest, origin=pos, ) tele_trig.solids.append( vmf.make_prism( pos + (-64, -64, -64), pos + (64, 64, -8), mat='tools/toolstrigger', ).solid, ) # Players, however get hurt as soon as they enter - that way it's # harder to see that they don't teleport. if block_type.is_top: if hurt_trig is None: hurt_trig = vmf.create_ent( classname='trigger_hurt', damagetype=32, # FALL spawnflags=1, # CLients damage=100000, nodmgforce=1, # No physics force when hurt.. damagemodel=0, # Always apply full damage. origin=pos, # We know this is not in the void.. ) hurt_trig.solids.append( vmf.make_prism( Vec(pos.x - 64, pos.y - 64, -128), pos + (64, 64, 48 if use_skybox else 16), mat='tools/toolstrigger', ).solid, ) if not block_type.is_bottom: continue # Everything else is only added to the bottom-most position. if use_skybox and blend_light: # Generate dim lights at the skybox location, # to blend the lighting together. light_pos = pos + (0, 0, -60) vmf.create_ent( classname='light', origin=light_pos, _light=blend_light, _fifty_percent_distance='256', _zero_percent_distance='512', ) vmf.create_ent( classname='light', origin=light_pos + tele_off, _light=blend_light, _fifty_percent_distance='256', _zero_percent_distance='512', ) wall_pos.update([(pos + off).as_tuple() for off in side_dirs]) if hurt_trig is not None: hurt_trig.outputs.append(Output( 'OnHurtPlayer', '@goo_fade', 'Fade', ), ) if not use_skybox: make_pit_shell(vmf) return # Now determine the position of side instances. # We use the utils.CONN_TYPES dict to determine instance positions # based on where nearby walls are. side_types = { utils.CONN_TYPES.side: PIT_INST['side'], # o| utils.CONN_TYPES.corner: PIT_INST['corner'], # _| utils.CONN_TYPES.straight: PIT_INST['side'], # Add this twice for |o| utils.CONN_TYPES.triple: PIT_INST['triple'], # U-shape utils.CONN_TYPES.all: PIT_INST['pillar'], # [o] } LOGGER.info('Pit instances: {}', side_types) for pos in wall_pos: pos = Vec(pos) if not brushLoc.POS['world':pos].is_solid: # Not actually a wall here! continue # CONN_TYPES has n,s,e,w as keys - whether there's something in that direction. nsew = tuple(brushLoc.POS['world':pos + off].is_pit for off in side_dirs) LOGGER.info('Pos: {}, NSEW: {}, lookup: {}', pos, nsew, utils.CONN_LOOKUP[nsew]) inst_type, angle = utils.CONN_LOOKUP[nsew] if inst_type is utils.CONN_TYPES.none: # Middle of the pit... continue rng = rand.seed(b'pit', pos.x, pos.y) file = rng.choice(side_types[inst_type]) if file != '': conditions.add_inst( vmf, file=file, targetname='goo_side', origin=tele_off + pos, angles=angle, ).make_unique() # Straight uses two side-instances in parallel - "|o|" if inst_type is utils.CONN_TYPES.straight: file = rng.choice(side_types[inst_type]) if file != '': conditions.add_inst( vmf, file=file, targetname='goo_side', origin=tele_off + pos, # Reverse direction angles=Angle.from_str(angle) + (0, 180, 0), ).make_unique()