def test_bbox_parse_block() -> None: """Test parsing of a block-shaped bbox from a VMF.""" vmf = VMF() ent = vmf.create_ent( 'bee2_collision_bbox', coll_deco=1, coll_physics=1, coll_grating=0, tags='standard excellent', ) ent.solids.append(vmf.make_prism(Vec(80, 10, 40), Vec(150, 220, 70)).solid) ent.solids.append(vmf.make_prism(Vec(-30, 45, 80), Vec(-20, 60, 120)).solid) bb2, bb1 = BBox.from_ent(ent) # Allow it to produce in either order. if bb1.min_x == -30: bb1, bb2 = bb2, bb1 assert_bbox( bb1, (80, 10, 40), (150, 220, 70), CollideType.DECORATION | CollideType.PHYSICS, {'standard', 'excellent'}, ) assert_bbox( bb2, (-30, 45, 80), (-20, 60, 120), CollideType.DECORATION | CollideType.PHYSICS, {'standard', 'excellent'}, )
def test_bbox_parse_plane(axis: str, mins: tuple3, maxes: tuple3) -> None: """Test parsing planar bboxes from a VMF. With 5 skip sides, the brush is flattened into the remaining plane. """ vmf = VMF() ent = vmf.create_ent('bee2_collision_bbox', coll_solid=1) prism = vmf.make_prism(Vec(80, 10, 40), Vec(150, 220, 70), mat='tools/toolsskip') getattr(prism, axis).mat = 'tools/toolsclip' ent.solids.append(prism.solid) [bbox] = BBox.from_ent(ent) assert_bbox(bbox, mins, maxes, CollideType.SOLID, set())
def test_vmf() -> None: """Verify the positions by generating a VMF.""" vmf = VMF() for shape in SHAPES: group = vmf.create_visgroup( shape.name.replace('/', '_').replace('fizzler', '').replace('__', '_')) for pos, angle in shape.points: ent = vmf.create_ent( 'prop_static', model='models/props_map_editor/fizzler.mdl', origin=pos @ angle, angles=angle, ) ent.visgroup_ids.add(group.id) ent.vis_shown = False print('Dumping shape.vmf') with open('shape.vmf', 'w') as f: vmf.export(f)
from srctools import VMF, Entity, Vec import os NUMS = [-1, 0, 1] VECS = [ Vec(x=1), Vec(y=1), Vec(z=1), Vec(x=-1), Vec(y=-1), Vec(z=-1), ] os.makedirs('instances/', exist_ok=True) inst_vmf = VMF() inst_vmf.create_ent( 'func_instance_parms', origin='0 0 0', parm1='$rot_pitch integer 0', parm2='$rot_roll integer 0', parm3='$rot_yaw integer 0', ) for pos in VECS: inst_vmf.create_ent('info_target', origin=pos * 128, targetname='offset', angles='0 0 0', rot_pitch='$rot_pitch',
None: 'INVERT', 'INVERT': None, } ExportedTemplate = NamedTuple('ExportedTemplate', [ ('world', List[Solid]), ('detail', Entity), ('overlay', List[Entity]), ('orig_ids', Dict[str, str]), ('template', 'Template'), ('origin', Vec), ('angles', Vec), ]) # Make_prism() generates faces aligned to world, copy the required UVs. realign_solid = VMF().make_prism(Vec(-16,-16,-16), Vec(16,16,16)).solid # type: 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.""" def __init__( self, temp_id, world: Dict[str, List[Solid]], detail: Dict[str, List[Solid]], overlays: Dict[str, List[Entity]],
'death_goo': 'goo', 'death_turret': 'turret', 'death_laserfield': 'laserfield', } class PossibleQuote(NamedTuple): priority: int lines: List[Property] # Create a fake instance to pass to condition flags. This way we can # reuse all that logic, without breaking flags that check the instance. fake_inst = VMF().create_ent( classname='func_instance', file='', angles='0 0 0', origin='0 0 0', ) def has_responses(): """Check if we have any valid 'response' data for Coop.""" return vbsp.GAME_MODE == 'COOP' and 'CoopResponses' in QUOTE_DATA def encode_coop_responses(vmf: VMF, pos: Vec, allow_dings: bool, voice_attrs: dict) -> None: """Write the coop responses information into the map.""" config = ConfigFile('bee2/resp_voice.cfg', in_conf_folder=False) response_block = QUOTE_DATA.find_key('CoopResponses', [])
def combine( bsp: BSP, bsp_ents: VMF, pack: PackList, game: Game, *, studiomdl_loc: Path = None, qc_folders: List[Path] = None, crowbar_loc: Optional[Path] = None, decomp_cache_loc: Path = None, auto_range: float = 0, min_cluster: int = 2, debug_tint: bool = False, debug_dump: bool = False, ) -> None: """Combine props in this map.""" # First parse out the bbox ents, so they are always removed. bbox_ents = list(bsp_ents.by_class['comp_propcombine_set']) for ent in bbox_ents: ent.remove() if not studiomdl_loc.exists(): LOGGER.warning('No studioMDL! Cannot propcombine!') return if not qc_folders and decomp_cache_loc is None: # If gameinfo is blah/game/hl2/gameinfo.txt, # QCs should be in blah/content/ according to Valve's scheme. # But allow users to override this. # If Crowbar's path is provided, that means they may want to just supply nothing. qc_folders = [game.path.parent.parent / 'content'] # Parse through all the QC files. LOGGER.info('Parsing QC files. Paths: \n{}', '\n'.join(map(str, qc_folders))) qc_map: Dict[str, Optional[QC]] = {} for qc_folder in qc_folders: load_qcs(qc_map, qc_folder) LOGGER.info('Done! {} props.', len(qc_map)) map_name = Path(bsp.filename).stem # Don't re-parse models continually. mdl_map: Dict[str, Optional[Model]] = {} # Wipe these, if they're being used again. _mesh_cache.clear() _coll_cache.clear() missing_qcs: Set[str] = set() def get_model(filename: str) -> Union[Tuple[QC, Model], Tuple[None, None]]: """Given a filename, load/parse the QC and MDL data. Either both are returned, or neither are. """ key = unify_mdl(filename) try: model = mdl_map[key] except KeyError: try: mdl_file = pack.fsys[filename] except FileNotFoundError: # We don't have this model, we can't combine... return None, None model = mdl_map[key] = Model(pack.fsys, mdl_file) if 'no_propcombine' in model.keyvalues.casefold(): mdl_map[key] = qc_map[key] = None return None, None if model is None or key in missing_qcs: return None, None try: qc = qc_map[key] except KeyError: if crowbar_loc is None: missing_qcs.add(key) return None, None qc = decompile_model(pack.fsys, decomp_cache_loc, crowbar_loc, filename, model.checksum) qc_map[key] = qc if qc is None: return None, None else: return qc, model # Ignore these two, they don't affect our new prop. relevant_flags = ~(StaticPropFlags.HAS_LIGHTING_ORIGIN | StaticPropFlags.DOES_FADE) def get_grouping_key(prop: StaticProp) -> Optional[tuple]: """Compute a grouping key for this prop. Only props with matching key can be possibly combined. If None it cannot be combined. """ qc, model = get_model(prop.model) if model is None or qc is None: return None return ( # Must be first, we pull this out later. frozenset({ tex.casefold().replace('\\', '/') for tex in model.iter_textures([prop.skin]) }), model.flags.value, (prop.flags & relevant_flags).value, model.contents, model.surfaceprop, prop.renderfx, *prop.tint, ) prop_count = 0 # First, construct groups of props that can possibly be combined. prop_groups = defaultdict( list) # type: Dict[Optional[tuple], List[StaticProp]] # This holds the list of all props we want in the map - # combined ones, and any we reject for whatever reason. final_props: List[StaticProp] = [] rejected: List[StaticProp] = [] if bbox_ents: LOGGER.info('Propcombine sets present ({}), combining...', len(bbox_ents)) grouper = group_props_ent( prop_groups, rejected, get_model, bbox_ents, min_cluster, ) elif auto_range > 0: LOGGER.info('Automatically finding propcombine sets...') grouper = group_props_auto( prop_groups, rejected, auto_range, min_cluster, ) else: # No way provided to choose props. LOGGER.info('No propcombine groups provided.') return for prop in bsp.static_props(): prop_groups[get_grouping_key(prop)].append(prop) prop_count += 1 # These are models we cannot merge no matter what - # no source files etc. cannot_merge = prop_groups.pop(None, []) final_props.extend(cannot_merge) LOGGER.debug( 'Prop groups: \n{}', '\n'.join([ f'{group}: {len(props)}' for group, props in sorted(prop_groups.items(), key=operator.itemgetter(0)) ])) group_count = 0 with ModelCompiler( game, studiomdl_loc, pack, map_name, 'propcombine', ) as compiler: for group in grouper: grouped_prop = combine_group(compiler, group, get_model) if debug_tint: # Compute a random hue, and convert back to RGB 0-255. r, g, b = colorsys.hsv_to_rgb(random.random(), 1, 1) grouped_prop.tint = Vec(round(r * 255), round(g * 255), round(b * 255)) final_props.append(grouped_prop) group_count += 1 final_props.extend(rejected) if debug_dump: dump_vmf = VMF() for prop in rejected: dump_vmf.create_ent( 'prop_static', model=prop.model, origin=prop.origin, angles=prop.angles, solid=prop.solidity, rendercolor=prop.tint, ) dump_fname = Path(bsp.filename).with_name(map_name + '_propcombine_reject.vmf') LOGGER.info('Dumping uncombined props to {}...', dump_fname) with dump_fname.open('w') as f: dump_vmf.export(f) LOGGER.info( 'Combined {} props into {}:\n - {} grouped models\n - {} ineligable\n - {} had no group', prop_count, len(final_props), group_count, len(cannot_merge), len(rejected), ) LOGGER.debug('Models with unknown QCs: \n{}', '\n'.join(sorted(missing_qcs))) # If present, delete old cache file. We'll have cleaned up the models. try: os.remove(compiler.model_folder_abs / 'cache.vdf') except FileNotFoundError: pass bsp.write_static_props(final_props)
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.copy().rotate(*template_data.angles) # 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: Dict[str, Optional[Portalable]] = 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 = color_picker.offset.copy().rotate(*template_data.angles) picker_pos += template_data.origin + sense_offset picker_norm = Vec(color_picker.normal).rotate(*template_data.angles) if color_picker.grid_snap: for axis in 'xyz': # Don't realign things in the normal's axis - # those are already fine. if not picker_norm[axis]: 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=tiledef.normal.z != 0, bevels=(False, False, False, False), 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 tile_setter in template.tile_setters: if not tile_setter.visgroups.issubset(template_data.visgroups): continue setter_pos = Vec(tile_setter.offset).rotate(*template_data.angles) setter_pos += template_data.origin + sense_offset setter_norm = Vec(tile_setter.normal).rotate(*template_data.angles) setter_type = tile_setter.tile_type # type: TileType 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: setter_type = 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 setter_type is None: raise ValueError( '"{}": Color picker "{}" has no tile to pick!'.format( template.id, tile_setter.picker_name)) 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[face.id] 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() random.seed(rand_prefix + norm.join('_')) 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() 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 = random.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(), 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=vbsp_options.get(float, 'goo_scale'), ) face.vaxis = UVAxis( 0, -1, 0, offset=0, scale=vbsp_options.get(float, 'goo_scale'), ) 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: mat = random.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
import srctools.logger from packages import ( PakObject, ParseData, get_config, ExportData, ) from srctools import VMF, Vec, Solid LOGGER = srctools.logger.get_logger(__name__) # Don't change face IDs when copying to here. # This allows users to refer to the stuff in templates specifically. # The combined VMF isn't to be compiled or edited outside of us, so it's fine # to have overlapping IDs between templates. TEMPLATE_FILE = VMF(preserve_ids=True) class BrushTemplate(PakObject, has_img=False, allow_mult=True): """A template brush which will be copied into the map, then retextured. This allows the sides of the brush to swap between wall/floor textures based on orientation. All world and detail brushes from the given VMF will be copied. """ # For scaling templates, maps normals to the prefix to use in the ent. NORMAL_TO_NAME = { (0, 0, 1): 'up', (0, 0, -1): 'dn', (0, 1, 0): 'n', (0, -1, 0): 's',