def combine( bsp: BSP, bsp_ents: VMF, pack: PackList, game: Game, studiomdl_loc: Path = None, qc_folders: List[Path] = None, auto_range: float = 0, min_cluster: int = 2, debug_tint: 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: # 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. 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 = {} # type: Dict[str, 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 = {} # type: Dict[str, Model] # Wipe these, if they're being used again. _mesh_cache.clear() _coll_cache.clear() def get_model(filename: str) -> Tuple[Optional[QC], Optional[Model]]: """Given a filename, load/parse the QC and MDL data.""" key = unify_mdl(filename) try: qc = qc_map[key] except KeyError: return None, None 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) return qc, model 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.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] = [] if bbox_ents: LOGGER.info('Propcombine sets present ({}), combining...', len(bbox_ents)) grouper = group_props_ent( prop_groups, final_props, get_model, bbox_ents, min_cluster, ) elif auto_range > 0: LOGGER.info('Automatically finding propcombine sets...') grouper = group_props_auto( prop_groups, final_props, 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. final_props.extend(prop_groups.pop(None, [])) 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. grouped_prop.tint = round( Vec(*colorsys.hsv_to_rgb(random.random(), 1, 1)) * 255) final_props.append(grouped_prop) LOGGER.info( 'Combined {} props to {} props using {} groups.', prop_count, len(final_props), compiler.model_folder, ) # 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 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)