def test_fixup_substitution() -> None: """Test Entity.fixup.substitute().""" ent = VMF().create_ent('any') ent.fixup['$var'] = 'out' assert ent.fixup.substitute('no var 1234') == 'no var 1234' assert ent.fixup.substitute('$var') == 'out' assert ent.fixup.substitute('prefix$varsuffix') == 'prefixoutsuffix' with raises(KeyError) as exc: ent.fixup.substitute('$notPRESent') assert exc.value.args == ('$notPRESent', ) assert ent.fixup.substitute('blah_$notPresent45:more', 'def') == 'blah_def:more' # Potential edge case - 1-long var. assert ent.fixup.substitute('blah$x:more', 'def') == 'blahdef:more' # Blank and fully numeric vars are not allowed. assert ent.fixup.substitute('blah$:more$45', 'def') == 'blah$:more$45' ent.fixup['$variable'] = 'long' # Value changed, should have remade the regex. assert ent.fixup.substitute('X=$variable') == 'X=long' # Check common prefixes don't break it. assert ent.fixup.substitute('_$var_and_$variable_') == '_out_and_long_' ent.fixup.update({'x': 'dunder'}) assert ent.fixup.substitute('__$x__') == '__dunder__' # Ensure regex chars in the var are escaped. ent.fixup['$a_var_with*_[]_regex'] = 'ignored' assert ent.fixup.substitute('V = $a_var_with*_[]_regex') == 'V = ignored'
def save_connectionpoint(item: Item, vmf: VMF) -> None: """Write connectionpoints to a VMF.""" for side, points in item.antline_points.items(): yaw = side.yaw inv_orient = Matrix.from_yaw(-yaw) for point in points: ant_pos = Vec(point.pos.x, -point.pos.y, -64) sign_pos = Vec(point.sign_off.x, -point.sign_off.y, -64) offset = (ant_pos - sign_pos) @ inv_orient try: skin = CONN_OFFSET_TO_SKIN[offset.as_tuple()] except KeyError: LOGGER.warning( 'Pos=({}), Sign=({}) -> ({}) is not a valid offset for signs!', point.pos, point.sign_off, offset) continue pos: Vec = round((ant_pos + sign_pos) / 2.0 * 16.0, 0) vmf.create_ent( 'bee2_editor_connectionpoint', origin=Vec(pos.x - 56, pos.y + 56, -64), angles=f'0 {yaw} 0', skin=skin, priority=point.priority, group_id='' if point.group is None else point.group, )
def read_ent_data(self) -> VMF: """Parse in entity data. This returns a VMF object, with entities mirroring that in the BSP. No brushes are read. """ ent_data = self.get_lump(BSP_LUMPS.ENTITIES) vmf = VMF() cur_ent = None # None when between brackets. seen_spawn = False # The first entity is worldspawn. # This code performs the same thing as property_parser, but simpler # since there's no nesting, comments, or whitespace, except between # key and value. We also operate directly on the (ASCII) binary. for line in ent_data.splitlines(): if line == b'{': if cur_ent is not None: raise ValueError( '2 levels of nesting after {} ents'.format( len(vmf.entities))) if not seen_spawn: cur_ent = vmf.spawn seen_spawn = True else: cur_ent = Entity(vmf) elif line == b'}': if cur_ent is None: raise ValueError( 'Too many closing brackets after {} ents'.format( len(vmf.entities))) if cur_ent is vmf.spawn: if cur_ent['classname'] != 'worldspawn': raise ValueError('No worldspawn entity!') else: # The spawn ent is stored in the attribute, not in the ent # list. vmf.add_ent(cur_ent) cur_ent = None elif line == b'\x00': # Null byte at end of lump. if cur_ent is not None: raise ValueError("Last entity didn't end!") return vmf else: # Line is of the form <"key" "val"> key, value = line.split(b'" "') decoded_key = key[1:].decode('ascii') decoded_val = value[:-1].decode('ascii') if 27 in value: # All outputs use the comma_sep, so we can ID them. cur_ent.add_out( Output.parse(Property(decoded_key, decoded_val))) else: # Normal keyvalue. cur_ent[decoded_key] = decoded_val # This keyvalue needs to be stored in the VMF object too. # The one in the entity is ignored. vmf.map_ver = conv_int(vmf.spawn['mapversion'], vmf.map_ver) return vmf
def save_embeddedvoxel(item: Item, vmf: VMF) -> None: """Save embedded voxel volumes.""" for bbox_min, bbox_max in bounding_boxes(item.embed_voxels): vmf.create_ent('bee2_editor_embeddedvoxel').solids.append( vmf.make_prism( Vec(bbox_min) * 128 + (-64.0, -64.0, -192.0), Vec(bbox_max) * 128 + (+64.0, +64.0, -64.0), # Entirely ignored, but makes it easier to distinguish. 'tools/toolshint', ).solid)
def save(item: Item) -> VMF: """Export out relevant item options into a VMF.""" vmf = VMF() with logger.context(item.id): for func in SAVE_FUNCS: func(item, vmf) return vmf
def save_occupiedvoxel(item: Item, vmf: VMF) -> None: """Save occupied voxel volumes.""" for voxel in item.occupy_voxels: pos = Vec(voxel.pos) * 128 if voxel.subpos is not None: pos += Vec(voxel.subpos) * 32 - (48, 48, 48) p1 = pos - (16.0, 16.0, 16.0) p2 = pos + (16.0, 16.0, 16.0) norm_dist = 32.0 - 4.0 else: p1 = pos - (64.0, 64.0, 64.0) p2 = pos + (64.0, 64.0, 64.0) norm_dist = 128.0 - 4.0 if voxel.normal is not None: for axis in ['x', 'y', 'z']: val = getattr(voxel.normal, axis) if val == +1: p2[axis] -= norm_dist elif val == -1: p1[axis] += norm_dist if voxel.against is not None: against = str(voxel.against).replace('COLLIDE_', '') else: against = '' vmf.create_ent( 'bee2_editor_occupiedvoxel', coll_type=str(voxel.type).replace('COLLIDE_', ''), coll_against=against, ).solids.append( vmf.make_prism( p1, p2, # Use clip for voxels, invisible for normals. # Entirely ignored, but makes it easier to use. 'tools/toolsclip' if voxel.normal is None else 'tools/toolsinvisible', ).solid)
def test_fixup_basic() -> None: """Test ent.fixup functionality.""" obj = object() # Arbitrary example object. ent = VMF().create_ent('any') assert len(ent.fixup) == 0 assert list(ent.fixup) == [] assert list(ent.fixup.keys()) == [] assert list(ent.fixup.values()) == [] assert list(ent.fixup.items()) == [] ent.fixup['$test'] = 'hello' assert ent.fixup['$test'] == 'hello', 'Standard' assert ent.fixup['$teSt'] == 'hello', 'Case-insentive' assert ent.fixup['test'] == 'hello', 'No $ sign is allowed' assert ent.fixup['$notPresent'] == '', 'Defaults to ""' assert ent.fixup['$test', 'default'] == 'hello', 'Unused default.' assert ent.fixup['not_here', 'default'] == 'default', 'Used default.' assert ent.fixup['not_here', obj] is obj, 'Default can be anything.' assert ent.fixup.get('$notPresent') == '', 'Defaults to ""' assert ent.fixup.get('$test', 'default') == 'hello', 'Unused default.' assert ent.fixup.get('not_here', obj) is obj, 'Default can be anything.' ent.fixup['$VALUE'] = 42 # Integer, converted to string. assert ent.fixup['$value'] == '42' ent.fixup['$value'] = 45.75 assert ent.fixup['$value'] == '45.75' ent.fixup['$true'] = True # Special case, bools become 1/0. assert ent.fixup['true'] == '1' ent.fixup['$false'] = False assert ent.fixup['$false'] == '0' # Order not guaranteed. assert len(ent.fixup) == 4 assert set(ent.fixup) == {'test', 'value', 'true', 'false'} assert set(ent.fixup.keys()) == {'test', 'value', 'true', 'false'} assert set(ent.fixup.values()) == {'hello', '1', '0', '45.75'} assert set(ent.fixup.items()) == { ('test', 'hello'), ('value', '45.75'), ('true', '1'), ('false', '0') } # Keys/values/items should have the same order. assert list(ent.fixup.items()) == list(zip(ent.fixup.keys(), ent.fixup.values()))
def read_ent_data(self) -> VMF: """Parse in entity data. This returns a VMF object, with entities mirroring that in the BSP. No brushes are read. """ ent_data = self.get_lump(BSP_LUMPS.ENTITIES) vmf = VMF() cur_ent = None # None when between brackets. seen_spawn = False # The first entity is worldspawn. # This code performs the same thing as property_parser, but simpler # since there's no nesting, comments, or whitespace, except between # key and value. We also operate directly on the (ASCII) binary. for line in ent_data.splitlines(): if line == b'{': if cur_ent is not None: raise ValueError( '2 levels of nesting after {} ents'.format( len(vmf.entities))) if not seen_spawn: cur_ent = vmf.spawn seen_spawn = True else: cur_ent = Entity(vmf) continue elif line == b'}': if cur_ent is None: raise ValueError(f'Too many closing brackets after' f' {len(vmf.entities)} ents!') if cur_ent is vmf.spawn: if cur_ent['classname'] != 'worldspawn': raise ValueError('No worldspawn entity!') else: # The spawn ent is stored in the attribute, not in the ent # list. vmf.add_ent(cur_ent) cur_ent = None continue elif line == b'\x00': # Null byte at end of lump. if cur_ent is not None: raise ValueError("Last entity didn't end!") return vmf if cur_ent is None: raise ValueError("Keyvalue outside brackets!") # Line is of the form <"key" "val"> key, value = line.split(b'" "') decoded_key = key[1:].decode('ascii') decoded_value = value[:-1].decode('ascii') # Now, we need to figure out if this is a keyvalue, # or connection. # If we're L4D+, this is easy - they use 0x1D as separator. # Before, it's a comma which is common in keyvalues. # Assume it's an output if it has exactly 4 commas, and the last two # successfully parse as numbers. if 27 in value: # All outputs use the comma_sep, so we can ID them. cur_ent.add_out( Output.parse(Property(decoded_key, decoded_value))) elif value.count(b',') == 4: try: cur_ent.add_out( Output.parse(Property(decoded_key, decoded_value))) except ValueError: cur_ent[decoded_key] = decoded_value else: # Normal keyvalue. cur_ent[decoded_key] = decoded_value # This keyvalue needs to be stored in the VMF object too. # The one in the entity is ignored. vmf.map_ver = conv_int(vmf.spawn['mapversion'], vmf.map_ver) return vmf
def retexture_template( template_data: ExportedTemplate, origin: Vec, fixup: EntityFixup = None, replace_tex: Mapping[str, Union[list[str], str]] = srctools.EmptyMapping, force_colour: Portalable = None, force_grid: TileSize = None, generator: GenCat = GenCat.NORMAL, sense_offset: Optional[Vec] = None, ): """Retexture a template at the given location. - Only textures in the TEMPLATE_RETEXTURE dict will be replaced. - Others will be ignored (nodraw, plasticwall, etc) - Wall textures pointing up and down will switch to floor/ceiling textures. - Textures of the same type, normal and inst origin will randomise to the same type. - replace_tex is a replacement table. This overrides everything else. The values should either be a list (random), or a single value. - If force_colour is set, all tile textures will be switched accordingly. If set to 'INVERT', white and black textures will be swapped. - If force_grid is set, all tile textures will be that size. - generator defines the generator category to use for surfaces. - Fixup is the inst.fixup value, used to allow $replace in replace_tex. - If sense_offset is set, color pickers and tilesetters will be treated as if they were locally offset this far in the template. """ template = template_data.template rev_id_mapping = { new_id: str(old_id) for old_id, new_id in template_data.orig_ids.items() } all_brushes = list(template_data.world) if template_data.detail is not None: all_brushes.extend(template_data.detail.solids) # Template faces are randomised per block and side. This means # multiple templates in the same block get the same texture, so they # can clip into each other without looking bad. rand_prefix = 'TEMPLATE_{0.x}_{0.y}_{0.z}:'.format(origin // 128) # Reprocess the replace_tex passed in, converting values. evalled_replace_tex: dict[str, list[str]] = {} for key, value in replace_tex.items(): if isinstance(value, str): value = [value] if fixup is not None: # Convert the material and key for fixup names. value = [ fixup[mat] if mat.startswith('$') else mat for mat in value ] if key.startswith('$'): key = fixup[key] # If starting with '#', it's a face id, or a list of those. if key.startswith('#'): for k in key[1:].split(): try: old_id = int(k) except (ValueError, TypeError): pass else: evalled_replace_tex.setdefault('#' + str(old_id), []).extend(value) else: evalled_replace_tex.setdefault(key.casefold(), []).extend(value) if sense_offset is None: sense_offset = Vec() else: sense_offset = sense_offset @ template_data.orient # For each face, if it needs to be forced to a colour, or None if not. # If a string it's forced to that string specifically. force_colour_face: dict[str, Union[Portalable, str, None]] = defaultdict(lambda: None) # Picker names to their results. picker_results = template_data.picker_results picker_type_results: dict[str, Optional[TileType]] = {} # If the "use patterns" option is enabled, face ID -> temp face to copy from. picker_patterned: dict[str, Optional[Side]] = defaultdict(lambda: None) # Then also a cache of the tiledef -> dict of template faces. pattern_cache: dict[tiling.TileDef, dict[tuple[int, int], Side]] = {} # Already sorted by priority. for color_picker in template.color_pickers: if not color_picker.visgroups.issubset(template_data.visgroups): continue picker_pos = round( color_picker.offset @ template_data.orient + template_data.origin + sense_offset, 6) picker_norm = round(color_picker.normal @ template_data.orient, 6) if color_picker.grid_snap: for axis in 'xyz': # Don't realign things in the normal's axis - # those are already fine. if abs(picker_norm[axis]) < 0.01: picker_pos[axis] = picker_pos[axis] // 128 * 128 + 64 try: tiledef, u, v = tiling.find_tile(picker_pos, picker_norm) except KeyError: # Doesn't exist. But only set if not already present. if color_picker.name: picker_results.setdefault(color_picker.name, None) picker_type_results.setdefault(color_picker.name, None) continue tile_type = tiledef[u, v] picker_type_results[color_picker.name] = tile_type try: tile_color = tile_type.color except ValueError: # Not a tile with color (void, etc). Treat as missing a color. picker_results.setdefault(color_picker.name, None) continue if color_picker.name and picker_results.get(color_picker.name, None) is None: picker_results[color_picker.name] = tile_color if color_picker.use_pattern: # Generate the brushwork for the tile to determine the top faces # required. We can then throw away the brushes themselves. try: pattern = pattern_cache[tiledef] except KeyError: pattern = pattern_cache[tiledef] = {} tiledef.gen_multitile_pattern( VMF(), {(u, v): tiledef[u, v] for u in (0, 1, 2, 3) for v in (0, 1, 2, 3)}, is_wall=abs(tiledef.normal.z) > 0.01, bevels=set(), normal=tiledef.normal, face_output=pattern, ) for side in color_picker.sides: if picker_patterned[side] is None and (u, v) in pattern: picker_patterned[side] = pattern[u, v] else: # Only do the highest priority successful one. for side in color_picker.sides: if force_colour_face[side] is None: if tile_color is tile_color.WHITE: force_colour_face[ side] = color_picker.force_tex_white or tile_color else: force_colour_face[ side] = color_picker.force_tex_black or tile_color if color_picker.after is AfterPickMode.VOID: tiledef[u, v] = TileType.VOID elif color_picker.after is AfterPickMode.NODRAW: tiledef[u, v] = TileType.NODRAW for voxel_setter in template.voxel_setters: if not voxel_setter.visgroups.issubset(template_data.visgroups): continue setter_pos = round( voxel_setter.offset @ template_data.orient + template_data.origin + sense_offset, 6) setter_norm = round(voxel_setter.normal @ template_data.orient, 6) norm_axis = setter_norm.axis() u_axis, v_axis = Vec.INV_AXIS[norm_axis] offsets = (-48, -16, 16, 48) for uoff in offsets: for voff in offsets: tiling.edit_quarter_tile( setter_pos + Vec.with_axes(u_axis, uoff, v_axis, voff), setter_norm, voxel_setter.tile_type, silent=True, # Don't log missing positions. force=voxel_setter.force, ) for tile_setter in template.tile_setters: if not tile_setter.visgroups.issubset(template_data.visgroups): continue setter_pos = round( tile_setter.offset @ template_data.orient + template_data.origin + sense_offset, 6) setter_norm = round(tile_setter.normal @ template_data.orient, 6) setter_type: TileType = tile_setter.tile_type if tile_setter.color == 'copy': if not tile_setter.picker_name: raise ValueError('"{}": Tile Setter set to copy mode ' 'must have a color picker!'.format( template.id)) # If a color picker is set, it overrides everything else. try: picker_res = picker_type_results[tile_setter.picker_name] except KeyError: raise ValueError('"{}": Tile Setter specified color picker ' '"{}" which does not exist!'.format( template.id, tile_setter.picker_name)) if picker_res is None: raise ValueError( '"{}": Color picker "{}" has no tile to pick!'.format( template.id, tile_setter.picker_name)) setter_type = picker_res elif setter_type.is_tile: if tile_setter.picker_name: # If a color picker is set, it overrides everything else. try: setter_color = picker_results[tile_setter.picker_name] except KeyError: raise ValueError( '"{}": Tile Setter specified color picker ' '"{}" which does not exist!'.format( template.id, tile_setter.picker_name)) if setter_color is None: raise ValueError( '"{}": Color picker "{}" has no tile to pick!'.format( template.id, tile_setter.picker_name)) elif isinstance(tile_setter.color, Portalable): # The color was specifically set. setter_color = tile_setter.color elif isinstance(force_colour, Portalable): # Otherwise it copies the forced colour - # it needs to be white or black. setter_color = force_colour else: # We need a forced color, but none was provided. raise ValueError( '"{}": Tile Setter set to use colour value from the ' "template's overall color, " 'but not given one!'.format(template.id)) # Inverting applies to all of these. if force_colour == 'INVERT': setter_color = ~setter_color setter_type = TileType.with_color_and_size( setter_type.tile_size, setter_color, ) tiling.edit_quarter_tile( setter_pos, setter_norm, setter_type, silent=True, # Don't log missing positions. force=tile_setter.force, ) for brush in all_brushes: for face in brush: orig_id = rev_id_mapping.get(face.id) or '' if orig_id in template.skip_faces: continue template_face = picker_patterned[orig_id] if template_face is not None: face.mat = template_face.mat face.uaxis = template_face.uaxis.copy() face.vaxis = template_face.vaxis.copy() continue folded_mat = face.mat.casefold() norm = face.normal() if orig_id in template.realign_faces: try: uaxis, vaxis = REALIGN_UVS[norm.as_tuple()] except KeyError: LOGGER.warning( 'Realign face in template "{}" ({} in final) is ' 'not on grid!', template.id, face.id, ) else: face.uaxis = uaxis.copy() face.vaxis = vaxis.copy() elif orig_id in template.vertical_faces: # Rotate the face in increments of 90 degrees, so it is as # upright as possible. pos_u = face.uaxis pos_v = face.vaxis # If both are zero, we're on the floor/ceiling and this is # pointless. if pos_u.z != 0 or pos_v.z != 0: neg_u = UVAxis(-pos_u.x, -pos_u.y, -pos_u.z, pos_u.offset, pos_u.scale) neg_v = UVAxis(-pos_v.x, -pos_v.y, -pos_v.z, pos_v.offset, pos_v.scale) # Each row does u, v = v, -u to rotate 90 degrees. # We want whichever makes V point vertically. face.uaxis, face.vaxis = max([ (pos_u, pos_v), (pos_v, neg_u), (neg_u, neg_v), (neg_v, pos_u), ], key=lambda uv: -uv[1].z) override_mat: Optional[list[str]] try: override_mat = evalled_replace_tex['#' + orig_id] except KeyError: try: override_mat = evalled_replace_tex[folded_mat] except KeyError: override_mat = None if override_mat is not None: # Replace_tex overrides everything. mat = rand.seed(b'template', norm, face.get_origin()).choice(override_mat) if mat[:1] == '$' and fixup is not None: mat = fixup[mat] if mat.startswith('<') and mat.endswith('>'): # Lookup in the style data. gen, mat = texturing.parse_name(mat[1:-1]) mat = gen.get(face.get_origin() + face.normal(), mat) # If blank, don't set. if mat: face.mat = mat continue if folded_mat == 'tile/white_wall_tile003b': LOGGER.warning( '"{}": white_wall_tile003b has changed definitions.', template.id) try: tex_type = TEMPLATE_RETEXTURE[folded_mat] except KeyError: continue # It's nodraw, or something we shouldn't change tex_colour: Optional[Portalable] gen_type, tex_name, tex_colour = tex_type if not gen_type.is_tile: # It's something like squarebeams or backpanels, so # we don't need much special handling. texturing.apply(gen_type, face, tex_name) if tex_name in ('goo', 'goo_cheap'): if norm != (0, 0, -1): # Goo must be facing upright! # Retexture to nodraw, so a template can be made with # all faces goo to work in multiple orientations. face.mat = 'tools/toolsnodraw' else: # Goo always has the same orientation! face.uaxis = UVAxis( 1, 0, 0, offset=0, scale=options.get(float, 'goo_scale') or 0.25, ) face.vaxis = UVAxis( 0, -1, 0, offset=0, scale=options.get(float, 'goo_scale') or 0.25, ) continue else: assert isinstance(tex_colour, Portalable) # Allow overriding to panel or bullseye types. if gen_type is GenCat.NORMAL: gen_type = generator # Otherwise, it's a panel wall or the like if force_colour_face[orig_id] is not None: tex_colour = force_colour_face[orig_id] if isinstance(tex_colour, str): face.mat = tex_colour continue elif force_colour == 'INVERT': # Invert the texture tex_colour = ~tex_colour elif force_colour is not None: tex_colour = force_colour if force_grid is not None: tex_name = force_grid texturing.apply(gen_type, face, tex_name, tex_colour) for over in template_data.overlay[:]: over_pos = Vec.from_str(over['basisorigin']) mat = over['material'].casefold() if mat in replace_tex: rng = rand.seed(b'temp', template_data.template.id, over_pos, mat) mat = rng.choice(replace_tex[mat]) if mat[:1] == '$' and fixup is not None: mat = fixup[mat] if mat.startswith('<') or mat.endswith('>'): mat = mat[1:-1] gen, tex_name = texturing.parse_name(mat[1:-1]) mat = gen.get(over_pos, tex_name) else: try: sign_type = consts.Signage(mat) except ValueError: pass else: mat = texturing.OVERLAYS.get(over_pos, sign_type) if mat == '': # If blank, remove the overlay from the map and the list. # (Since it's inplace, this can affect the tuple.) template_data.overlay.remove(over) over.remove() else: over['material'] = mat
def import_template( vmf: VMF, temp_name: Union[str, Template], origin: Vec, angles: Optional[Union[Angle, Matrix]] = None, targetname: str = '', force_type: TEMP_TYPES = TEMP_TYPES.default, add_to_map: bool = True, additional_visgroups: Iterable[str] = (), bind_tile_pos: Iterable[Vec] = (), align_bind: bool = False, coll: collisions.Collisions = None, coll_add: Optional[ collisions.CollideType] = collisions.CollideType.NOTHING, coll_mask: collisions.CollideType = collisions.CollideType.EVERYTHING, ) -> ExportedTemplate: """Import the given template at a location. * `temp_name` can be a string, or a template instance. * `visgroups` is a list of additional visgroups to use after the ones in the name string (if given). * If `force_type` is set to 'detail' or 'world', all brushes will be converted to the specified type instead. A list of world brushes and the func_detail entity will be returned. If there are no detail brushes, None will be returned instead of an invalid entity. * If `targetname` is set, it will be used to localise overlay names. add_to_map sets whether to add the brushes and func_detail to the map. * IF `coll` is provided, the template may have bee2_collision volumes. The targetname must be provided in this case. * If any `bound_tile_pos` are provided, these are offsets to tiledefs which should have all the overlays in this template bound to them, and vice versa. * If `align_bind` is set, these will be first aligned to grid. * `coll_mask` and `coll_force` allow modifying the collision types added. `coll_mask` is AND-ed with the bbox type, then `coll_add` is OR-ed in. If the collide type ends up being NOTHING, it is skipped. """ import vbsp if isinstance(temp_name, Template): template, temp_name = temp_name, temp_name.id chosen_groups: set[str] = set() else: temp_name, chosen_groups = parse_temp_name(temp_name) template = get_template(temp_name) chosen_groups.update(additional_visgroups) chosen_groups.add('') orig_world, orig_detail, orig_over = template.visgrouped(chosen_groups) new_world: list[Solid] = [] new_detail: list[Solid] = [] new_over: list[Entity] = [] # A map of the original -> new face IDs. id_mapping: dict[int, int] = {} orient = to_matrix(angles) for orig_list, new_list in [(orig_world, new_world), (orig_detail, new_detail)]: for old_brush in orig_list: brush = old_brush.copy( vmf_file=vmf, side_mapping=id_mapping, keep_vis=False, ) brush.localise(origin, orient) new_list.append(brush) for overlay in orig_over: new_overlay = overlay.copy( vmf_file=vmf, keep_vis=False, ) del new_overlay[ 'template_id'] # Remove this, it's not part of overlays new_overlay['classname'] = 'info_overlay' sides = overlay['sides'].split() new_overlay['sides'] = ' '.join( str(id_mapping[int(side)]) for side in sides if int(side) in id_mapping) srctools.vmf.localise_overlay(new_overlay, origin, orient) orig_target = new_overlay['targetname'] # Only change the targetname if the overlay is not global, and we have # a passed name. if targetname and orig_target and orig_target[0] != '@': new_overlay['targetname'] = targetname + '-' + orig_target vmf.add_ent(new_overlay) new_over.append(new_overlay) # Don't let the overlays get retextured too! vbsp.IGNORED_OVERLAYS.add(new_overlay) if force_type is TEMP_TYPES.detail: new_detail.extend(new_world) new_world.clear() elif force_type is TEMP_TYPES.world: new_world.extend(new_detail) new_detail.clear() if add_to_map: vmf.add_brushes(new_world) detail_ent: Optional[Entity] = None if new_detail: detail_ent = vmf.create_ent(classname='func_detail') detail_ent.solids = new_detail if not add_to_map: detail_ent.remove() if bind_tile_pos: # Bind all our overlays without IDs to a set of tiles, # and add any marked faces to those tiles to be given overlays. new_overlay_faces = set( map(id_mapping.get, map(int, template.overlay_faces))) new_overlay_faces.discard(None) bound_overlay_faces = [ face for brush in (new_world + new_detail) for face in brush.sides if face.id in new_overlay_faces ] tile_norm = orient.up() for tile_off in bind_tile_pos: tile_off = tile_off.copy() tile_off.localise(origin, orient) for axis in ('xyz' if align_bind else ''): # Don't realign things in the normal's axis - # those are already fine. if abs(tile_norm[axis]) < 1e-6: tile_off[axis] = tile_off[axis] // 128 * 128 + 64 try: tile = tiling.TILES[tile_off.as_tuple(), tile_norm.as_tuple()] except KeyError: LOGGER.warning( 'No tile to bind at {} for "{}"!', tile_off, template.id, ) continue for over in new_over: if over['sides'] == '': tile.bind_overlay(over) tile.brush_faces.extend(bound_overlay_faces) if template.collisions: if coll is None: LOGGER.warning( 'Template "{}" has collisions, but unable to apply these!', template.id) elif targetname: for coll_def in template.collisions: if not coll_def.visgroups.issubset(chosen_groups): continue contents = (coll_def.bbox.contents & coll_mask) | coll_add if contents is not contents.NOTHING: bbox = coll_def.bbox @ orient + origin coll.add( bbox.with_attrs(name=targetname, contents=contents)) else: LOGGER.warning( 'With collisions provided, the instance name must not be blank!' ) return ExportedTemplate( world=new_world, detail=detail_ent, overlay=new_over, orig_ids=id_mapping, template=template, origin=origin, orient=orient, visgroups=chosen_groups, picker_results={}, # Filled by retexture_template. picker_type_results={}, )
""" world: list[Solid] detail: Optional[Entity] overlay: list[Entity] orig_ids: dict[int, int] template: 'Template' origin: Vec orient: Matrix visgroups: set[str] picker_results: dict[str, Optional[Portalable]] picker_type_results: dict[str, Optional[TileType]] # Make_prism() generates faces aligned to world, copy the required UVs. realign_solid: Solid = VMF().make_prism(Vec(-16, -16, -16), Vec(16, 16, 16)).solid REALIGN_UVS = { face.normal().as_tuple(): (face.uaxis, face.vaxis) for face in realign_solid } del realign_solid class Template: """Represents a template before it's imported into a map.""" _data: dict[str, tuple[list[Solid], list[Solid], list[Entity]]] def __init__( self, temp_id: str, visgroup_names: set[str],
def import_template( vmf: VMF, temp_name: Union[str, Template], origin: Vec, angles: Optional[Union[Angle, Matrix]] = None, targetname: str = '', force_type: TEMP_TYPES = TEMP_TYPES.default, add_to_map: bool = True, additional_visgroups: Iterable[str] = (), visgroup_choose: Callable[[Iterable[str]], Iterable[str]] = lambda x: (), bind_tile_pos: Iterable[Vec] = (), ) -> ExportedTemplate: """Import the given template at a location. temp_name can be a string, or a template instance. visgroups is a list of additional visgroups to use after the ones in the name string (if given). If force_type is set to 'detail' or 'world', all brushes will be converted to the specified type instead. A list of world brushes and the func_detail entity will be returned. If there are no detail brushes, None will be returned instead of an invalid entity. If targetname is set, it will be used to localise overlay names. add_to_map sets whether to add the brushes and func_detail to the map. visgroup_choose is a callback used to determine if visgroups should be added - it's passed a list of names, and should return a list of ones to use. If any bound_tile_pos are provided, these are offsets to tiledefs which should have all the overlays in this template bound to them, and vice versa. """ import vbsp if isinstance(temp_name, Template): template, temp_name = temp_name, temp_name.id chosen_groups = set() # type: Set[str] else: temp_name, chosen_groups = parse_temp_name(temp_name) template = get_template(temp_name) chosen_groups.update(additional_visgroups) chosen_groups.update(visgroup_choose(template.visgroups)) chosen_groups.add('') orig_world, orig_detail, orig_over = template.visgrouped(chosen_groups) new_world = [] # type: List[Solid] new_detail = [] # type: List[Solid] new_over = [] # type: List[Entity] # A map of the original -> new face IDs. id_mapping = {} # type: Dict[int, int] orient = to_matrix(angles) for orig_list, new_list in [(orig_world, new_world), (orig_detail, new_detail)]: for old_brush in orig_list: brush = old_brush.copy( vmf_file=vmf, side_mapping=id_mapping, keep_vis=False, ) brush.localise(origin, orient) new_list.append(brush) for overlay in orig_over: # type: Entity new_overlay = overlay.copy( vmf_file=vmf, keep_vis=False, ) del new_overlay[ 'template_id'] # Remove this, it's not part of overlays new_overlay['classname'] = 'info_overlay' sides = overlay['sides'].split() new_overlay['sides'] = ' '.join( str(id_mapping[int(side)]) for side in sides if int(side) in id_mapping) srctools.vmf.localise_overlay(new_overlay, origin, orient) orig_target = new_overlay['targetname'] # Only change the targetname if the overlay is not global, and we have # a passed name. if targetname and orig_target and orig_target[0] != '@': new_overlay['targetname'] = targetname + '-' + orig_target vmf.add_ent(new_overlay) new_over.append(new_overlay) # Don't let the overlays get retextured too! vbsp.IGNORED_OVERLAYS.add(new_overlay) if force_type is TEMP_TYPES.detail: new_detail.extend(new_world) new_world.clear() elif force_type is TEMP_TYPES.world: new_world.extend(new_detail) new_detail.clear() if add_to_map: vmf.add_brushes(new_world) detail_ent: Optional[Entity] = None if new_detail: detail_ent = vmf.create_ent(classname='func_detail') detail_ent.solids = new_detail if not add_to_map: detail_ent.remove() if bind_tile_pos: # Bind all our overlays without IDs to a set of tiles, # and add any marked faces to those tiles to be given overlays. new_overlay_faces = set(map(id_mapping.get, template.overlay_faces)) new_overlay_faces.discard(None) bound_overlay_faces = [ face for brush in (new_world + new_detail) for face in brush.sides if face.id in new_overlay_faces ] tile_norm = orient.up() for tile_off in bind_tile_pos: tile_off = tile_off.copy() tile_off.localise(origin, orient) try: tile = tiling.TILES[tile_off.as_tuple(), tile_norm.as_tuple()] except KeyError: LOGGER.warning( 'No tile to bind at {} for "{}"!', tile_off, template.id, ) continue for over in new_over: if over['sides'] == '': tile.bind_overlay(over) tile.brush_faces.extend(bound_overlay_faces) return ExportedTemplate( world=new_world, detail=detail_ent, overlay=new_over, orig_ids=id_mapping, template=template, origin=origin, orient=orient, visgroups=chosen_groups, picker_results={}, # Filled by retexture_template. picker_type_results={}, )
def setup(self, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> None: """Build the list of clump locations.""" assert self.portal is not None assert self.orient is not None # Convert the generator key to a generator-specific seed. # That ensures different surfaces don't end up reusing the same # texture indexes. self.gen_seed = int.from_bytes( self.category.name.encode() + self.portal.name.encode() + self.orient.name.encode(), 'big', ) LOGGER.info('Generating texture clumps...') clump_length: int = self.options['clump_length'] clump_width: int = self.options['clump_width'] # The tiles currently present in the map. orient_z = self.orient.z remaining_tiles: Set[Tuple[float, float, float]] = { (tile.pos + 64 * tile.normal // 128 * 128).as_tuple() for tile in tiles if tile.normal.z == orient_z } # A global RNG for picking clump positions. clump_rand = random.Random(global_seed + '_clumping') pos_min = Vec() pos_max = Vec() # For debugging, generate skip brushes with the shape of the clumps. debug_visgroup: Optional[VisGroup] if self.options['clump_debug']: debug_visgroup = vmf.create_visgroup( f'{self.category.name}_{self.orient.name}_{self.portal.name}') else: debug_visgroup = None while remaining_tiles: # Pick from a random tile. tile_pos = next( itertools.islice( remaining_tiles, clump_rand.randrange(0, len(remaining_tiles)), len(remaining_tiles), )) remaining_tiles.remove(tile_pos) pos = Vec(tile_pos) # Clumps are long strips mainly extended in one direction # In the other directions extend by 'width'. It can point any axis. direction = clump_rand.choice('xyz') for axis in 'xyz': if axis == direction: dist = clump_length else: dist = clump_width pos_min[axis] = pos[axis] - clump_rand.randint(0, dist) * 128 pos_max[axis] = pos[axis] + clump_rand.randint(0, dist) * 128 remaining_tiles.difference_update( map(Vec.as_tuple, Vec.iter_grid(pos_min, pos_max, 128))) self._clump_locs.append( Clump( pos_min.x, pos_min.y, pos_min.z, pos_max.x, pos_max.y, pos_max.z, # We use this to reseed an RNG, giving us the same textures # each time for the same clump. clump_rand.getrandbits(32), )) if debug_visgroup is not None: # noinspection PyUnboundLocalVariable debug_brush: Solid = vmf.make_prism( pos_min - 64, pos_max + 64, 'tools/toolsskip', ).solid debug_brush.visgroup_ids.add(debug_visgroup.id) debug_brush.vis_shown = False vmf.add_brush(debug_brush) LOGGER.info( '{}.{}.{}: {} Clumps for {} tiles', self.category.name, self.orient.name, self.portal.name, len(self._clump_locs), len(tiles), )