|
| 1 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +# SPDX-FileCopyrightText: 2026 sp00ktober |
| 3 | +"""What the Add panel adds, and where: a shape and its sizes, or a copy of the selection, |
| 4 | +`pack`ed into a group's free primitives and those of the objects it replaces.""" |
| 5 | + |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +from pathlib import Path |
| 9 | +from typing import TYPE_CHECKING, Any |
| 10 | + |
| 11 | +import numpy as np |
| 12 | + |
| 13 | +from .core import shapes |
| 14 | +from .core.edit import OBJECT, EditError, Selection |
| 15 | +from .core.scene import Array, MeshGroup |
| 16 | + |
| 17 | +if TYPE_CHECKING: |
| 18 | + from .workspace import MapWorkspace |
| 19 | + |
| 20 | +COPY = "copy" |
| 21 | +KINDS = (*shapes.SHAPES, COPY) |
| 22 | +#: where it goes: the selection's bottom centre, the last click, or the typed point |
| 23 | +PLACES = ("selection", "click", "typed") |
| 24 | +UV_MODES = ("planar", "keep", "obj") |
| 25 | +SOLIDS: tuple[bool | str | None, ...] = (None, "box", True) |
| 26 | + |
| 27 | + |
| 28 | +class AddForm: |
| 29 | + """The Add panel's values; the workspace keeps them, so a panel can come and go.""" |
| 30 | + |
| 31 | + def __init__(self, ws: MapWorkspace) -> None: |
| 32 | + self.ws = ws |
| 33 | + #: indices into KINDS, PLACES, UV_MODES and SOLIDS |
| 34 | + self.shape = 0 |
| 35 | + self.at_mode = 0 |
| 36 | + self.uv = 0 |
| 37 | + self.solid = 1 |
| 38 | + self.size = [400.0, 300.0, 400.0] |
| 39 | + self.radius = 200.0 |
| 40 | + self.height = 400.0 |
| 41 | + self.segments = 8 |
| 42 | + self.rings = 6 |
| 43 | + self.divisions = 1 |
| 44 | + self.rotate = 0.0 |
| 45 | + self.at = [0.0, 0.0, 0.0] |
| 46 | + #: the target group, an index into the scene's groups |
| 47 | + self.group = 0 |
| 48 | + self.replace = True |
| 49 | + self.colour = True |
| 50 | + self.copy_scale = 1.0 |
| 51 | + #: the last add's outcome |
| 52 | + self.message = "" |
| 53 | + |
| 54 | + @property |
| 55 | + def kind(self) -> str: |
| 56 | + return KINDS[self.shape] |
| 57 | + |
| 58 | + def placement(self) -> Array: |
| 59 | + """The selection's bottom centre, the last click, or the typed point.""" |
| 60 | + ws = self.ws |
| 61 | + if self.at_mode == 0 and not ws.selection.empty and ws.scene is not None: |
| 62 | + lo, hi = ws.selection.bounds(ws.scene) |
| 63 | + return np.array([(lo[0] + hi[0]) / 2, lo[1], (lo[2] + hi[2]) / 2], np.float64) |
| 64 | + if self.at_mode == 1 and ws.tools.last_pick is not None: |
| 65 | + return np.asarray(ws.tools.last_pick.point, np.float64) |
| 66 | + return np.asarray(self.at, np.float64) |
| 67 | + |
| 68 | + def shape_args(self, kind: str) -> dict[str, Any]: |
| 69 | + if kind == "box" or kind == "ramp": |
| 70 | + return {"size": tuple(self.size)} |
| 71 | + if kind == "plane": |
| 72 | + return {"size": (self.size[0], self.size[2]), "divisions": self.divisions} |
| 73 | + if kind == "cylinder": |
| 74 | + return {"radius": self.radius, "height": self.height, "segments": self.segments} |
| 75 | + if kind == "sphere": |
| 76 | + return {"radius": self.radius, "rings": self.rings, "segments": self.segments} |
| 77 | + return {} |
| 78 | + |
| 79 | + def target(self) -> MeshGroup | None: |
| 80 | + """The group it goes into.""" |
| 81 | + sc = self.ws.scene |
| 82 | + if sc is None or not sc.groups: |
| 83 | + return None |
| 84 | + return sc.groups[max(0, min(self.group, len(sc.groups) - 1))] |
| 85 | + |
| 86 | + def sacrifice(self) -> Selection | None: |
| 87 | + """The selected objects whose primitives the shape may take.""" |
| 88 | + g, sel = self.target(), self.ws.selection |
| 89 | + if g is None or not self.replace or self.kind == COPY or g.key not in sel.vertices: |
| 90 | + return None |
| 91 | + return sel |
| 92 | + |
| 93 | + def needs(self) -> int: |
| 94 | + """Triangles the shape or the copy has.""" |
| 95 | + sc = self.ws.scene |
| 96 | + if self.kind == COPY: |
| 97 | + return self.ws.selection.n_faces(sc) if sc is not None else 0 |
| 98 | + return shapes.triangle_count(self.kind, **self.shape_args(self.kind)) |
| 99 | + |
| 100 | + def capacity(self) -> tuple[int, int]: |
| 101 | + """Triangles that fit: with the sacrifice, and in free primitives alone.""" |
| 102 | + g, sess = self.target(), self.ws.session |
| 103 | + if g is None or sess is None: |
| 104 | + return 0, 0 |
| 105 | + return sess.capacity(g.key, self.sacrifice()), sess.capacity(g.key, None) |
| 106 | + |
| 107 | + def add(self) -> Selection | None: |
| 108 | + """Writes the shape into the document's assets/ and packs it; the new object, or None |
| 109 | + with `message` saying why not.""" |
| 110 | + ws = self.ws |
| 111 | + sc, sess, g = ws.scene, ws.session, self.target() |
| 112 | + if sc is None or sess is None or g is None: |
| 113 | + return None |
| 114 | + if sess.base_dir is None: |
| 115 | + self.message = "save the document first: the shape is written into its assets/" |
| 116 | + return None |
| 117 | + kind, at = self.kind, tuple(self.placement()) |
| 118 | + try: |
| 119 | + if kind in ("cylinder", "sphere"): |
| 120 | + v, t, uv = shapes.make(kind, at=at, **self.shape_args(kind)) |
| 121 | + stem = kind |
| 122 | + elif kind != COPY: |
| 123 | + v, t, uv = shapes.make(kind, at=at, rotate_y=self.rotate, **self.shape_args(kind)) |
| 124 | + stem = kind |
| 125 | + else: |
| 126 | + if ws.selection.empty: |
| 127 | + self.message = "nothing selected to copy" |
| 128 | + return None |
| 129 | + k = next(iter(ws.selection.vertices)) |
| 130 | + ids = ws.selection.vertices[k] |
| 131 | + v, t, uv = shapes.from_group( |
| 132 | + sc, k, ids, at=at, scale=self.copy_scale, rotate_y=self.rotate |
| 133 | + ) |
| 134 | + stem = f"copy_g{k[1]}" |
| 135 | + except (KeyError, ValueError) as e: |
| 136 | + self.message = f"shape: {e}" |
| 137 | + return None |
| 138 | + name = shapes.next_asset_name(sess.base_dir, stem) |
| 139 | + shapes.write_obj(sess.base_dir / name, v, t, uv, stem) |
| 140 | + try: |
| 141 | + new = sess.add_mesh( |
| 142 | + g.key, |
| 143 | + name, |
| 144 | + len(t), |
| 145 | + sacrifice=self.sacrifice(), |
| 146 | + uv=UV_MODES[self.uv], |
| 147 | + colour=self._colour(), |
| 148 | + solid=SOLIDS[self.solid], |
| 149 | + ) |
| 150 | + except EditError as e: |
| 151 | + self.message = f"refused: {e}" |
| 152 | + (sess.base_dir / name).unlink(missing_ok=True) |
| 153 | + return None |
| 154 | + self.message = f"added {Path(name).name} ({len(t)} triangles) into {g.label} as {name}" |
| 155 | + warnings = sess.warnings() |
| 156 | + if warnings: |
| 157 | + self.message += " [!] " + warnings[0] |
| 158 | + ws.after_commit(sess.ops[-1:]) |
| 159 | + ws.tools.kind = OBJECT |
| 160 | + ws.tools.select(new) |
| 161 | + return new |
| 162 | + |
| 163 | + def _colour(self) -> list[int] | None: |
| 164 | + """The selection's mean vertex colour, when asked for and there is one.""" |
| 165 | + ws = self.ws |
| 166 | + if not self.colour or ws.selection.empty or ws.scene is None: |
| 167 | + return None |
| 168 | + sc = ws.scene |
| 169 | + cols = [ |
| 170 | + cs[ids] |
| 171 | + for k, ids in ws.selection.vertices.items() |
| 172 | + if (cs := sc.group(*k).colours) is not None |
| 173 | + ] |
| 174 | + return [int(x) for x in np.concatenate(cols).mean(0).round()] if cols else None |
0 commit comments