# Grouper: the hull's skin, its openings' glass and trims, and the cabin lining (exec'd by build_grouper.py after gr_shape.py). # # Round 3 (2026-10-22, the user: "fix the topology and lines needs to be smooth"): the skin is built by gr_skin.py, # gr_skin_layout.py, gr_skin_mesh.py, gr_skin_relax.py and gr_skin_build.py (a structured quad layout of a rounded box: a # cylinder of rings from the duct to the nose and a cap over the nose with four poles; hole rings at the openings; fair design # lines; relaxed on the analytic surface; see gr_skin.py's head). This file keeps what sits on it: the glass (each pane its own # ladder, a closed 4 mm slab), the swept trims of one constant section round the windows and the doorways (now sampled so the # outline turns at most TRIM_TURN degrees per vertex, its spacing changing gently), and the cabin's lining (its own grid of # planar rings on the hull's sections without the pods, sunk 113 mm; its openings found geometrically round the trims, the # glass, the doorway and the collar's bore). for _f in ("gr_skin.py", "gr_skin_layout.py", "gr_skin_mesh.py", "gr_skin_relax.py", "gr_skin_build.py"): exec(open(os.path.join(HERE, _f), encoding="utf-8").read()) REPORT["skin_seconds"] = round(time.time() - SKIN_T0, 1) def prof_at(y, pod=True): """(the section at y, its arc lengths) as round 2's gr_hull gave them (gr_cabin.py reads them)""" P = profile(float(y), pod) return P, arc_of(P) # ------------------------------------------------------------------ glass: each window's own ladder over its smooth outline, sunk 22 mm glass = MB("SM_Grouper_Glass", [ACRYL]) GLASS_T = 0.004 def glass_slab(bmg): """(critique round 2, M3) each pane a closed slab GLASS_T thick (inward), not one surface: a single refracting surface bent every view ray into the cabin toward the glass's normal (the concept camera saw the dashboard, not the port wall's screens), and from inside the cabin a one-sided pane is culled in Unreal""" bmg.normal_update() out = sum((f.normal * f.calc_area() for f in bmg.faces), Vector()).normalized() c0 = sum((v.co for v in bmg.verts), Vector()) / len(bmg.verts) res = bmesh.ops.solidify(bmg, geom=bmg.faces[:], thickness=GLASS_T) new = [v for v in res["geom"] if isinstance(v, bmesh.types.BMVert)] if new and (sum((v.co for v in new), Vector()) / len(new) - c0).dot(out) > 0: # it went outward: the other way for v in new: v.co -= out * (2 * GLASS_T) bmg.normal_update() bmesh.ops.recalc_face_normals(bmg, faces=bmg.faces[:]) bmg.normal_update() if sum((f.normal * f.calc_area()).dot(out) for f in bmg.faces if (f.calc_center_median() - c0).dot(out) > -GLASS_T * 0.5) < 0: bmesh.ops.reverse_faces(bmg, faces=bmg.faces[:]) def glass_side(poly, sg, step=0.05, rows=6): ys_ = [p[0] for p in poly] y0, y1 = min(ys_) + 0.002, max(ys_) - 0.002 n = max(4, int(math.ceil((y1 - y0) / step))) cols = [] for j in range(n + 1): t = 0.5 - 0.5 * math.cos(math.pi * j / n) y = y0 + (y1 - y0) * t sp = span_at(poly, y) if sp is None: continue lo, hi = sp col = [] for r in range(rows + 1): p, nn = on_side(sg, y, lo + (hi - lo) * r / rows) col.append(p - nn * GLASS_D) cols.append(col) bmg = bmesh.new() VV = [[bmg.verts.new(p) for p in col] for col in cols] for j in range(len(VV) - 1): for r in range(rows): bmg.faces.new((VV[j][r], VV[j + 1][r], VV[j + 1][r + 1], VV[j][r + 1])) bmg.normal_update() if sum(f.normal.x * sg for f in bmg.faces) < 0: bmesh.ops.reverse_faces(bmg, faces=bmg.faces[:]) glass_slab(bmg) glass.merge(bmg, ACRYL) for sg in (1, -1): glass_side(P_SG, sg) def glass_front(nl=10): n = 18 bmg = bmesh.new() rows = [] for q in range(n + 1): y = Y_FG1 + 0.001 + (Y_FG0 - Y_FG1 - 0.002) * (0.5 - 0.5 * math.cos(math.pi * q / n)) hw = fg_hw(y) row = [] for c in range(nl + 1): p, nn = on_top(-hw + 2 * hw * c / nl, y) row.append(bmg.verts.new(p - nn * GLASS_D)) rows.append(row) for q in range(n): for c in range(nl): bmg.faces.new((rows[q][c], rows[q][c + 1], rows[q + 1][c + 1], rows[q + 1][c])) bmg.normal_update() if sum(f.normal.y for f in bmg.faces) > 0: bmesh.ops.reverse_faces(bmg, faces=bmg.faces[:]) glass_slab(bmg) glass.merge(bmg, ACRYL) glass_front() # ------------------------------------------------------------------ swept trims of one constant section TRIM_TURN = 4.0 # deg per vertex at most round the windows and doorways (G2 outlines: the turning ramps gently) TRIM_CORNER_TURN = 6.0 # ... and in their round corners (radius under 0.25 m) TRIM_OUTLINES = {} def adaptive(poly, max_step=0.07, max_turn_deg=TRIM_TURN, to3d=None): """(round 3) points along a closed outline (chart units, metres): the spacing is the smaller of max_step and the length over which the outline turns max_turn_deg, smoothed along the loop (it changes at most 25 % from one point to the next), so the turning per vertex rises and falls gently through a corner (round 2 took a point whenever 11 deg had accumulated). With to3d the lengths and the turning are the outline's own on the surface (in its tangent plane): a chart's axes stretch on the raked nose and round the hull's turn, and the chart's turning was not the trim's""" pts = resample_closed(poly, 0.002 if to3d is None else 0.0008) P = np.array(pts, float) n = len(P) if to3d is not None: Q3, N3_ = [], [] for (u, v) in pts: q, nn = to3d(u, v) Q3.append(tuple(q)); N3_.append(tuple(nn)) Q3 = np.array(Q3); N3_ = np.array(N3_) for _ in range(30): # Taubin pairs: the rays' noise out, the shape kept for lam_ in (0.5, -0.53): Q3 = Q3 + lam_ * (0.5 * (np.roll(Q3, 1, 0) + np.roll(Q3, -1, 0)) - Q3) d3 = np.roll(Q3, -1, 0) - Q3 seg = np.linalg.norm(d3, axis=1) a_ = d3 - N3_ * (d3 * N3_).sum(1)[:, None] # the outgoing edge in the vertex's tangent plane b_ = np.roll(d3, 1, 0) b_ = b_ - N3_ * (b_ * N3_).sum(1)[:, None] # the incoming one c_ = (a_ * b_).sum(1) / np.maximum(1e-12, np.linalg.norm(a_, axis=1) * np.linalg.norm(b_, axis=1)) turn = np.arccos(np.clip(c_, -1.0, 1.0)) else: d = np.roll(P, -1, 0) - P seg = np.hypot(d[:, 0], d[:, 1]) ang = np.arctan2(d[:, 1], d[:, 0]) turn = np.abs((ang - np.roll(ang, 1) + np.pi) % (2 * np.pi) - np.pi) k = turn / np.maximum(1e-9, 0.5 * (seg + np.roll(seg, 1))) w = 12 if to3d is not None else 25 # (3D: a 20 mm window; a wider one flattened tight corners) k = np.convolve(np.concatenate([k[-w:], k, k[:w]]), np.ones(2 * w + 1) / (2 * w + 1), mode="same")[w:-w] lim = np.full(n, math.radians(max_turn_deg)) if to3d is not None: # designed round corners (radius under 0.25 m): 6 deg a vertex lim = np.where(k > 1.0 / 0.20, math.radians(max(max_turn_deg, TRIM_CORNER_TURN) - 0.3), lim * 0.95) h = np.minimum(max_step, lim / np.maximum(1e-6, k)) for _ in range(3): # gentle: at most +25 % per point, both ways round the loop for i in range(1, 2 * n): a_i, b_i = (i - 1) % n, i % n h[b_i] = min(h[b_i], h[a_i] * (1.0 + 0.25 * seg[a_i] / max(1e-9, h[a_i]))) for i in range(2 * n, 0, -1): a_i, b_i = i % n, (i - 1) % n h[b_i] = min(h[b_i], h[a_i] * (1.0 + 0.25 * seg[b_i] / max(1e-9, h[a_i]))) dens = np.concatenate([[0.0], np.cumsum(seg / h)]) m = max(8, int(round(dens[-1]))) t = np.linspace(0.0, dens[-1], m, endpoint=False) idx = np.searchsorted(dens, t, side="right") - 1 f = (t - dens[idx]) / np.maximum(1e-12, dens[idx + 1] - dens[idx]) Q = P[idx] + (np.roll(P, -1, 0)[idx] - P[idx]) * f[:, None] return [tuple(q) for q in Q] def frame_sweep(mb, poly, to3d, prof, mat, sleeve=None, max_step=0.07, flange=0.056, max_turn=11.0, tag=None): """a trim along a closed chart outline: to3d(u, v) -> (point, normal) on its reference surface; prof [(a, h)] (a out of the opening along the surface, h out of it); sleeve(p, n, o) -> extra points continuing the section inward. The outward direction is worked out in 3D (perpendicular to the outline on the surface, away from its middle: a chart's axes stretch on the raked nose), and smoothed over the neighbours; a sleeve's flange is shortened where the outline turns tighter than its length (it would fold over itself)""" pts2 = adaptive(poly, max_step, max_turn, to3d if tag else None) n = len(pts2) P3, N3 = [], [] for (u, v) in pts2: p, nrm = to3d(u, v) P3.append(p); N3.append(nrm) if tag: # (round 3) a Taubin pass along the loop: the points come off rays onto the sampled surface and carry a few tenths of a # millimetre of noise, which at the corners' 3-6 mm spacing turned the outline by several degrees per vertex for lam_ in (0.5, -0.53) * 4: P3 = [P3[j] + lam_ * (0.5 * (P3[j - 1] + P3[(j + 1) % n]) - P3[j]) for j in range(n)] cen = sum(P3, Vector()) / n O3, RC = [], [] for j in range(n): t = P3[(j + 1) % n] - P3[j - 1] o = t.cross(N3[j]) o = (o - o.dot(N3[j]) * N3[j]).normalized() RC.append(t) O3.append(o) if sum(O3[j].dot(P3[j] - cen) for j in range(n)) < 0: O3 = [-o for o in O3] O3 = [(O3[j - 1] + 2 * O3[j] + O3[(j + 1) % n]).normalized() for j in range(n)] rc = [] for j in range(n): t0 = (P3[j] - P3[j - 1]); t1 = (P3[(j + 1) % n] - P3[j]) ang = t0.angle(t1) if t0.length > 1e-9 and t1.length > 1e-9 else 0.0 rc.append(0.5 * (t0.length + t1.length) / ang if ang > 1e-6 else 9.0) loops = [[P3[j] + O3[j] * a + N3[j] * h for j in range(n)] for a, h in prof] if sleeve is not None: rcs = [min(rc[j - 1], rc[j], rc[(j + 1) % n]) for j in range(n)] # (round 2) shortened only where the outline turns OUTWARD (a concave stretch, where an outward flange would fold); # round a convex corner the outward flange fans out and keeps its length (a shortened one left gaps at the windows' # corners) def _concave(j): t0 = (P3[j] - P3[j - 1]).normalized() t1 = (P3[(j + 1) % n] - P3[j]).normalized() return (t1 - t0).dot(O3[j]) > 1e-4 cc = [_concave(j) for j in range(n)] cc = [cc[j - 1] or cc[j] or cc[(j + 1) % n] for j in range(n)] ext = [sleeve(P3[j], N3[j], O3[j] * (min(1.0, 0.8 * rcs[j] / flange) if cc[j] else 1.0)) for j in range(n)] REPORT.setdefault("sweep_concave_points", []).append(sum(cc)) for q in range(len(ext[0])): loops.append([ext[j][q] for j in range(n)]) grid_rings(mb, loops, mat, expect=(1, n // 4, tuple(N3[n // 4]))) if tag: # (round 3) the openings' outlines for the topology check TRIM_OUTLINES[tag] = {"p": [tuple(p) for p in P3], "n": [tuple(q) for q in N3]} return P3, N3, O3 # ------------------------------------------------------------------ the cabin lining's grid (round 3: its own planar rings on the hull's # sections without the pods, from the front glass's bottom to the rear bulkhead; round 2 sank the skin's own grid, which no # longer runs in rings round the nose) def liner_at(y): """the lining's depth under the skin: 113 mm (SECTION A-A's wall), thinning to 56 mm on the nose, where the hull curves faster than that (a parallel surface 113 mm in would fold there)""" return LINER * (1.0 - 0.5 * smoothstep(858.0, 885.0, ximg(y))) M_L = 72 # the lining's columns round the hull (it is only seen through the windows and the hatch) _L_STEP = 0.06 Y_LF = Y_FG1 _nl = max(4, int(round((Y_BULK - Y_LF) / _L_STEP))) L_YS = [Y_LF + (Y_BULK - Y_LF) * q / _nl for q in range(_nl + 1)] I_LF, I_BULK = 0, _nl def _lining_ring2d(y): P = _resample(profile(float(y), False).p, M_L // 2) P[0, 0] = P[-1, 0] = 0.0 return P _LR2 = [_lining_ring2d(y) for y in L_YS] _LR2 = [_lining_ring2d(L_YS[0] - 0.01)] + _LR2 + [_lining_ring2d(L_YS[-1] + 0.01)] # a ring each side for the normals _LYS = [L_YS[0] - 0.01] + L_YS + [L_YS[-1] + 0.01] def _ring3(P2, y): out = [Vector((P2[j, 0], y, P2[j, 1])) for j in range(M_L // 2 + 1)] out += [Vector((-P2[j, 0], y, P2[j, 1])) for j in range(M_L // 2 - 1, 0, -1)] return out _LB = [_ring3(P2, y) for P2, y in zip(_LR2, _LYS)] BASE_L, NRM_L = [], [] for i in range(1, len(_LB) - 1): ring = _LB[i] nr = [] for k in range(M_L): tl = _LB[i + 1][k] - _LB[i - 1][k] tc = ring[(k + 1) % M_L] - ring[(k - 1) % M_L] n = tl.cross(tc) o2 = Vector((ring[k].x, 0.0, ring[k].z - (-0.15))) # outward hint: away from the hull's axis if n.length < 1e-12: n = o2 if n.dot(o2) < 0: n = -n nr.append(n.normalized()) BASE_L.append(ring) NRM_L.append(nr) def sink_of(i): zs = [p.z for p in BASE_L[i]] return min(liner_at(L_YS[i]), 0.32 * (max(zs) - min(zs))) def _lining_endwall_y(): """the cabin's front end wall (the lining's first ring, sunk along the raked nose's normals): its aft-most point""" return max(BASE_L[I_LF][k].y - NRM_L[I_LF][k].y * sink_of(I_LF) for k in range(M_L)) frames = MB("_frames", [FRAME]) # the windows' inner lip: 16 mm over the glass, 12 mm proud of the frame band, its outer edge sunk 3 mm into the band, # then a sleeve straight down to the lining WIN_PROF = [(0.022, -0.003), (0.016, 0.010), (-0.008, 0.011), (-0.016, 0.002), (0.0, -0.010)] # 5 points (round 1: 7) # critique round 2 (m2): the windows' inner flange reaches 0.16 m out under the lining (was 0.05): the lining's opening is up # to two grid faces wider than the window, and through the 5 cm flange's gap the skin's underside was seen from outside # through the glass (from inside the cabin, a see-through gap in Unreal). The lining's opening is fitted to the trims WITHOUT # their flanges (FRAMES_TREE from a probe sweep), so the lining runs on over the flange and the reveal is closed. WIN_FL = 0.16 # the cabin's front end wall (the lining's first ring, I_LF = I_FG1, sunk along the raked nose's normals): its aft-most point Y_ENDWALL = None # set by the lining below (its front end wall); win_sleeve reads it late Y_ENDWALL = _lining_endwall_y() def _skin_along(q, n): """the base skin's point and normal under q along the opening's own normal n (a vertical or sideways ray picked the wrong face on the nose's steep sides)""" hit = SKIN.ray_cast(q + n * 0.25, -n, 0.6) if hit[0] is None: return None, None return hit[0], _snorm(hit[0], hit[2]) def win_sleeve(p, n, o, fl=WIN_FL, proj=None): """down to 22 mm past the lining, then the inner flange out over the lining's opening (which is up to two grid faces wider than the outline), its edge turned back toward the lining. (round 2, m2) The flange follows the lining's curve 16 mm on the cabin side of it (proj: the skin's point and normal under a point; the lining lies liner_at(y) under the skin), so a long flange neither leaves the lining nor runs through it""" d = LV_FRAME + liner_at(p.y) + 0.022 S0, S1 = p - n * (GLASS_D + 0.004), p - n * d if fl <= 0.0: return [S0, S1] q_ = S1 + o * fl # no further forward than 12 mm aft of the cabin's front end wall (the y_lim = Y_ENDWALL + 0.012 # front glass's lower edge and corners: the flange would cross the wall) if q_.y < y_lim and S1.y > y_lim: fl = max(0.05, fl * (S1.y - y_lim) / max(1e-6, S1.y - q_.y)) elif q_.y < y_lim: fl = 0.05 k = max(1e-6, o.length) od = o / k for _ in range(8): out = [S0, S1] for t, extra in ((0.5 * fl, 0.016), (fl - 0.006, 0.016), (fl, 0.008)): Q, N = _skin_along(p + od * (t * k), n) if (proj is not None and fl > 0.06) else (None, None) # short: straight if Q is None: out.append(S1 + o * t + (n * 0.008 if extra < 0.01 else Vector())) else: out.append(Q - N * (liner_at(Q.y) + extra)) if fl <= 0.05 or min(q.y for q in out[2:]) >= y_lim or S1.y < y_lim: break fl = max(0.05, fl * 0.7) # the flange as it lies would cross the end wall: shorter return out for sg in (1, -1): frame_sweep(frames, P_SG, lambda u, v, sg=sg: on_side(sg, u, v, LV_FRAME), WIN_PROF, FRAME, lambda p, n, o, sg=sg: win_sleeve(p, n, o, fl=WIN_FL - 0.06 * smoothstep(0.3, 0.8, -o.z / max(1e-6, o.length)), proj=lambda q: on_side(sg, q.y, q.z)), flange=WIN_FL, max_step=0.12, max_turn=TRIM_TURN, tag="side_glass_" + ("L" if sg > 0 else "R")) frame_sweep(frames, [(u, v) for u, v in FG_POLY], lambda u, v: on_top(u, v, LV_FRAME), WIN_PROF, FRAME, lambda p, n, o: win_sleeve(p, n, o, fl=0.05 + (WIN_FL - 0.05) * smoothstep(0.05, 0.25, p.z), proj=lambda q: on_top(q.x, q.y)), flange=WIN_FL, max_step=0.12, max_turn=TRIM_TURN, tag="front_glass") # (the front glass's flange is long only along its upper half, under the ceiling; at its lower corners the lining turns from # the nose's end into the side walls within a few cm and a long flange ran through it) # the doorway's trim on the band: a flange and a rounded top round the opening, then a sleeve going in HORIZONTALLY (across the # door's vertical hinge axis), so the leaf, which is the same horizontal extrusion, swings out past its jambs DOOR_PROF = [(0.034, -0.003), (0.030, 0.006), (0.014, 0.010), (0.003, 0.008), (0.0, 0.002)] def door_sleeve(sg): """horizontal, to 10 mm past the lining (the band is 30 mm in, the lining 113 mm under the skin), then an inner flange 50 mm out over the lining's opening, parallel to the lining""" def f(p, n, o): sx = Vector((sg, 0, 0)) d = (liner_at(p.y) - (-LV_BAND) + 0.022) / max(0.35, abs(n.x)) q = p - sx * d o2 = (o - o.dot(n) * n).normalized() return [p - sx * 0.03, q, q + o2 * 0.050, q + o2 * 0.056 + n * 0.008] return f DOOR_TRIM = {} for sg in (1, -1): DOOR_TRIM[sg] = frame_sweep(frames, P_DOOR, lambda u, v, sg=sg: on_side(sg, u, v, LV_BAND), DOOR_PROF, FRAME, door_sleeve(sg), max_step=0.12, max_turn=TRIM_TURN, tag="door_" + ("L" if sg > 0 else "R")) # ------------------------------------------------------------------ the cabin lining (its faces; the openings: geometric) cab = MB("SM_Grouper_Cabin", [CABL, CABF, SEAT, SCREEN, CABD, FRAME]) bml = bmesh.new() lay_lin = bml.verts.layers.int.new("is_lining") LVX = {} def lv(i, k): k %= M_L if (i, k) not in LVX: v = bml.verts.new(BASE_L[i][k] - NRM_L[i][k] * sink_of(i)) v[lay_lin] = 1 LVX[(i, k)] = v return LVX[(i, k)] P_DOOR_OUT = side_poly(door_outline_px(-1.2, 12)) P_SG_LIN = _offset_closed_2d(P_SG, 0.03) FG_LIN = _offset_closed_2d(FG_POLY, 0.03) GLASS_TREE = BVHTree.FromBMesh(glass.bm) # the lining's opening keeps clear of the trims, their sleeves and the first 5 cm of their flanges (round 1's), not of the # windows' long flanges beyond that (they run under the lining) _probe = MB("_frames_probe", [FRAME]) for sg in (1, -1): frame_sweep(_probe, P_SG, lambda u, v, sg=sg: on_side(sg, u, v, LV_FRAME), WIN_PROF, FRAME, lambda p, n, o: win_sleeve(p, n, o, 0.05)) frame_sweep(_probe, [(u, v) for u, v in FG_POLY], lambda u, v: on_top(u, v, LV_FRAME), WIN_PROF, FRAME, lambda p, n, o: win_sleeve(p, n, o, 0.05), max_step=0.06) for sg in (1, -1): frame_sweep(_probe, P_DOOR, lambda u, v, sg=sg: on_side(sg, u, v, LV_BAND), DOOR_PROF, FRAME, door_sleeve(sg), max_step=0.06) FRAMES_TREE = BVHTree.FromBMesh(_probe.bm) _probe.bm.free() def lining_hole(q): """the lining's openings: round the windows (their outline seen along X / from above, 3 cm wider, and no lining face within 25 mm of a trim or 20 mm of the glass), the doorway's outline (+2 cm, its sleeve goes in horizontally), the collar's bore""" c = sum(q, Vector()) / 4 hit = GLASS_TREE.find_nearest(c) if hit[0] is not None and hit[3] < 0.02: return True if abs(c.x) > 0.25 or c.y < Y_FG0 + 0.1: hit = FRAMES_TREE.find_nearest(c) if hit[0] is not None and hit[3] < 0.025: return True if abs(c.x) > 0.30 and SG_YA - 0.05 <= c.y <= SG_YB + 0.05 and pip((c.y, c.z), P_SG_LIN): return True if c.y < Y_FG0 + 0.08 and c.z > -0.25 and pip((c.x, c.y), FG_LIN): return True if abs(c.x) > 0.6 and pip((c.y, c.z), P_DOOR_OUT): return True if c.z > 0.8 and (math.hypot(c.x, c.y - Y_CL) < 0.56 or min(math.hypot(p.x, p.y - Y_CL) for p in q) < 0.535): return True return False for i in range(I_BULK): for k in range(M_L): q = [BASE_L[a][b % M_L] - NRM_L[a][b % M_L] * sink_of(a) for a, b in ((i, k), (i + 1, k), (i + 1, k + 1), (i, k + 1))] if lining_hole(q): continue bml.faces.new((lv(i, k), lv(i + 1, k), lv(i + 1, (k + 1) % M_L), lv(i, (k + 1) % M_L))) for v in bml.verts: v[lay_lin] = 1 bml.normal_update() for f in bml.faces: # seen from inside c = f.calc_center_median() if f.normal.dot(normal_at(c)) > 0: f.normal_flip() cab.merge(bml, CABL) for ii, sgn in ((I_BULK, -1), (I_LF, 1)): # the rear bulkhead faces forward, the nose's end wall aft bmc_ = bmesh.new() lc_ = bmc_.verts.layers.int.new("is_lining") ring_ = [bmc_.verts.new(BASE_L[ii][k] - NRM_L[ii][k] * sink_of(ii)) for k in range(M_L)] q4_ = len(ring_) // 8 coons_cap(bmc_, ring_[q4_:] + ring_[:q4_], 0) near_trim = [f for f in bmc_.faces if (lambda h: h[0] is not None and h[3] < 0.025)(FRAMES_TREE.find_nearest(f.calc_center_median()))] if near_trim: # the window trims' flanges close the end wall there bmesh.ops.delete(bmc_, geom=near_trim, context='FACES') for v in bmc_.verts: v[lc_] = 1 bmc_.normal_update() if sum(f.normal.y for f in bmc_.faces) * sgn < 0: bmesh.ops.reverse_faces(bmc_, faces=bmc_.faces[:]) cab.merge(bmc_, CABL) REPORT["stage"] = "hull+glass+frames+lining"