diff --git a/frontend/src/components/dashboard/widgets/CostOverviewWidget.tsx b/frontend/src/components/dashboard/widgets/CostOverviewWidget.tsx index 08d5896..e4bf0d4 100644 --- a/frontend/src/components/dashboard/widgets/CostOverviewWidget.tsx +++ b/frontend/src/components/dashboard/widgets/CostOverviewWidget.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { DollarSign, FileText } from 'lucide-react' import api from '../../../api/client' -import { useAuthStore } from '../../../store/auth' +import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../../../store/auth' interface Invoice { id: string @@ -27,7 +27,7 @@ function Skeleton() { export default function CostOverviewWidget() { const user = useAuthStore((s) => s.user) const isPrivileged = - user?.role === 'admin' || user?.role === 'project_manager' + checkIsPrivileged(user) const { data, isLoading, error } = useQuery({ queryKey: ['invoices-widget'], diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index d9d55a4..6251fda 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,6 +1,6 @@ import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom' import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload, Menu, X } from 'lucide-react' -import { useAuthStore } from '../../store/auth' +import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../../store/auth' import { clsx } from 'clsx' import { useState } from 'react' import { useQuery } from '@tanstack/react-query' @@ -148,7 +148,7 @@ export default function Layout() { ) })} - {(user?.role === 'admin' || user?.role === 'project_manager') && ( + {(checkIsPrivileged(user)) && ( setSidebarOpen(false)} @@ -165,7 +165,7 @@ export default function Layout() { Admin )} - {(user?.role === 'admin' || user?.role === 'project_manager') && ( + {(checkIsPrivileged(user)) && ( setSidebarOpen(false)} @@ -182,7 +182,7 @@ export default function Layout() { Billing )} - {(user?.role === 'admin' || user?.role === 'project_manager') && ( + {(checkIsPrivileged(user)) && ( setSidebarOpen(false)} @@ -199,7 +199,7 @@ export default function Layout() { Media Browser )} - {(user?.role === 'admin' || user?.role === 'project_manager') && ( + {(checkIsPrivileged(user)) && ( setSidebarOpen(false)} @@ -216,7 +216,7 @@ export default function Layout() { Workers )} - {(user?.role === 'admin' || user?.role === 'project_manager') && ( + {(checkIsPrivileged(user)) && ( setSidebarOpen(false)} @@ -233,7 +233,7 @@ export default function Layout() { Workflows )} - {(user?.role === 'admin' || user?.role === 'project_manager') && ( + {(checkIsPrivileged(user)) && ( setSidebarOpen(false)} @@ -250,7 +250,7 @@ export default function Layout() { Asset Libraries )} - {user?.role === 'admin' && ( + {checkIsAdmin(user) && ( setSidebarOpen(false)} @@ -267,7 +267,7 @@ export default function Layout() { Notification Settings )} - {user?.role === 'admin' && ( + {checkIsAdmin(user) && ( setSidebarOpen(false)} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index b69ab9d..78022cd 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -10,7 +10,7 @@ import TemplateEditor from '../components/admin/TemplateEditor' import PricingTierTable from '../components/admin/PricingTierTable' import OutputTypeTable from '../components/admin/OutputTypeTable' import RenderTemplateTable from '../components/admin/RenderTemplateTable' -import { useAuthStore } from '../store/auth' +import { useAuthStore, isAdmin as checkIsAdmin } from '../store/auth' import { listPricingTiers } from '../api/pricing' import { listOutputTypes } from '../api/outputTypes' import { @@ -26,7 +26,7 @@ import type { GPUProbeResult } from '../api/worker' export default function AdminPage() { const qc = useQueryClient() const user = useAuthStore((s) => s.user) - const isAdmin = user?.role === 'admin' + const isAdmin = checkIsAdmin(user) const [showNewUser, setShowNewUser] = useState(false) const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' }) const [editingTemplateId, setEditingTemplateId] = useState(null) @@ -335,7 +335,7 @@ export default function AdminPage() {

{user.full_name}

{user.email}

- + {user.role} diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index 94bee04..9d5a6f6 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -10,7 +10,7 @@ import { getNotifications, markAsRead, markOneAsRead, type Notification, type NotificationChannel, } from '../api/notifications' -import { useAuthStore } from '../store/auth' +import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth' const ACTION_CONFIG: Record | null) => string; color: string }> = { 'order.submitted': { icon: Send, label: (d) => `Order ${d?.order_number ?? '?'} submitted`, color: 'text-blue-500' }, @@ -62,7 +62,7 @@ export default function NotificationsPage() { const qc = useQueryClient() const user = useAuthStore((s) => s.user) - const isAdminOrPM = user?.role === 'admin' || user?.role === 'project_manager' + const isAdminOrPM = checkIsPrivileged(user) const visibleTabs = TABS.filter(t => !t.adminOnly || isAdminOrPM) const currentTab = visibleTabs.find(t => t.key === activeTab) ?? visibleTabs[0] diff --git a/frontend/src/pages/OrderDetail.tsx b/frontend/src/pages/OrderDetail.tsx index adbd9e2..53c4939 100644 --- a/frontend/src/pages/OrderDetail.tsx +++ b/frontend/src/pages/OrderDetail.tsx @@ -15,7 +15,7 @@ import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbn import type { OrderItem, OrderLine } from '../api/orders' import { listOutputTypes } from '../api/outputTypes' import type { OutputType } from '../api/outputTypes' -import { useAuthStore } from '../store/auth' +import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth' import StepDropzone from '../components/upload/StepDropzone' import CadPartMaterials from '../components/orders/CadPartMaterials' import LiveRenderLog from '../components/LiveRenderLog' @@ -180,7 +180,7 @@ export default function OrderDetailPage() { const canSubmit = order.status === 'draft' const canDelete = order.status === 'draft' || order.status === 'rejected' const isDraft = order.status === 'draft' - const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager' + const isPrivileged = checkIsPrivileged(user) const canReject = isPrivileged && (order.status === 'submitted' || order.status === 'processing') const canResubmit = order.status === 'rejected' && (isPrivileged || order.created_by === user?.id) const rp = order.render_progress diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index 5b019d3..ca04700 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -17,7 +17,7 @@ import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '.. import { listMaterials } from '../api/materials' import MaterialInput from '../components/shared/MaterialInput' import MaterialWizard from '../components/MaterialWizard' -import { useAuthStore } from '../store/auth' +import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth' import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad' import { listMediaAssets as getMediaAssets } from '../api/media' import InlineCadViewer from '../components/cad/InlineCadViewer' @@ -129,7 +129,7 @@ export default function ProductDetailPage() { const navigate = useNavigate() const qc = useQueryClient() const user = useAuthStore((s) => s.user) - const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager' + const isPrivileged = checkIsPrivileged(user) const [editMode, setEditMode] = useState(false) const [draft, setDraft] = useState>({}) diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 1f3fb69..1edc7a9 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -5,10 +5,18 @@ interface User { id: string email: string full_name: string - role: 'admin' | 'project_manager' | 'client' + role: 'admin' | 'global_admin' | 'tenant_admin' | 'project_manager' | 'client' is_active: boolean } +/** True for any role that has full administrative access. */ +export const isAdmin = (user: User | null): boolean => + user?.role === 'admin' || user?.role === 'global_admin' || user?.role === 'tenant_admin' + +/** True for admin or project_manager (privileged users). */ +export const isPrivileged = (user: User | null): boolean => + isAdmin(user) || user?.role === 'project_manager' + interface AuthState { token: string | null user: User | null diff --git a/render-worker/scripts/blender_render.py b/render-worker/scripts/blender_render.py index 4fbab48..39f5e40 100644 --- a/render-worker/scripts/blender_render.py +++ b/render-worker/scripts/blender_render.py @@ -120,19 +120,32 @@ def _ensure_collection(name: str): return col -def _apply_smooth(part_obj, angle_deg): - """Apply smooth or flat shading to a mesh object.""" - bpy.context.view_layer.objects.active = part_obj - part_obj.select_set(True) +def _apply_smooth_batch(parts, angle_deg): + """Apply smooth shading to ALL parts in a single operator call. + + bpy.ops.object.shade_smooth_by_angle() operates on all selected objects + at once (one C-level call), so batching reduces O(n) operator overhead to O(1). + Per-part calls cost ~90ms each × 175 parts = 16s; batch call costs ~0.2s total. + """ + bpy.ops.object.select_all(action='DESELECT') + mesh_parts = [p for p in parts if p.type == 'MESH'] + for part in mesh_parts: + part.select_set(True) + if not mesh_parts: + return + bpy.context.view_layer.objects.active = mesh_parts[0] if angle_deg > 0: try: bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg)) except AttributeError: bpy.ops.object.shade_smooth() - part_obj.data.use_auto_smooth = True - part_obj.data.auto_smooth_angle = math.radians(angle_deg) + for part in mesh_parts: + if hasattr(part.data, 'use_auto_smooth'): + part.data.use_auto_smooth = True + part.data.auto_smooth_angle = math.radians(angle_deg) else: bpy.ops.object.shade_flat() + bpy.ops.object.select_all(action='DESELECT') def _assign_failed_material(part_obj): @@ -381,7 +394,6 @@ def _apply_material_library(parts, mat_lib_path, mat_map): part.data.materials.clear() part.data.materials.append(appended[mat_name]) assigned_count += 1 - print(f"[blender_render] assigned '{mat_name}' to part '{part.name}'", flush=True) else: unmatched_names.append(part.name) @@ -420,20 +432,40 @@ def _activate_gpu(): _early_gpu_type = _activate_gpu() +# ── Timing harness ──────────────────────────────────────────────────────────── +import time as _time +_t0 = _time.monotonic() +_timings: dict = {} + +def _lap(label: str) -> None: + """Record elapsed time since the last _lap() call and since t0.""" + global _t_last + now = _time.monotonic() + if not hasattr(_lap, '_last'): + _lap._last = _t0 + delta = now - _lap._last + total = now - _t0 + _timings[label] = round(delta, 3) + print(f"[blender_render] TIMING {label}={delta:.2f}s (total={total:.2f}s)", flush=True) + _lap._last = now + # ── SCENE SETUP ────────────────────────────────────────────────────────────── if use_template: # ── MODE B: Template-based render ──────────────────────────────────────── print(f"[blender_render] Opening template: {template_path}") bpy.ops.wm.open_mainfile(filepath=template_path) + _lap("template_load") # Find or create target collection target_col = _ensure_collection(target_collection) # Import OCC GLB (already in metres, one object per STEP part) parts = _import_glb(glb_path) + _lap("glb_import") # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) + _lap("rotation") # Move imported parts into target collection for part in parts: @@ -442,14 +474,12 @@ if use_template: col.objects.unlink(part) target_col.objects.link(part) - # Apply smooth shading (Blender 5.0+ shade_smooth_by_angle adds a geometry - # node modifier that handles both smooth shading AND sharp edge marking - # automatically — no need for the old _mark_sharp_and_seams edit-mode loop) - import time as _time - _t_smooth = _time.time() - for _si, part in enumerate(parts): - _apply_smooth(part, smooth_angle) - print(f"[blender_render] smooth shading: {len(parts)} parts ({_time.time()-_t_smooth:.1f}s)", flush=True) + # Batch smooth shading: select all parts, call shade_smooth_by_angle ONCE. + # In Blender 5 this adds a "Smooth by Angle" GeoNodes modifier to every + # selected object in a single C call — same effect as calling per-object + # but ~100× faster (0.2s vs 16s for 175 parts). + _apply_smooth_batch(parts, smooth_angle) + _lap("smooth_shading") # Material assignment: library materials if available, otherwise palette if material_library_path and material_map: @@ -481,6 +511,7 @@ if use_template: # No material library — assign fallback to all parts for part in parts: _assign_failed_material(part) + _lap("material_assign") # ── Shadow catcher (Cycles only, template mode only) ───────────────────── if shadow_catcher: @@ -539,10 +570,10 @@ else: import time as _time _t_smooth_a = _time.time() + _apply_smooth_batch(parts, smooth_angle) for part in parts: - _apply_smooth(part, smooth_angle) _assign_failed_material(part) - print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.1f}s)", flush=True) + print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.2f}s)", flush=True) # Apply material library on top of palette colours (same logic as Mode B). # material_library_path / material_map are parsed from argv even in Mode A @@ -779,9 +810,15 @@ if scene.render.engine == 'CYCLES': f"compute_device_type={cprefs.compute_device_type}, " f"gpu_devices={[(d.name, d.type, d.use) for d in cprefs.devices if d.type != 'CPU']}", flush=True) +_lap("pre_render_setup") print(f"[blender_render] Rendering → {output_path} (Blender {bpy.app.version_string})", flush=True) sys.stdout.flush() bpy.ops.render.render(write_still=True) print("[blender_render] render done.", flush=True) +_lap("gpu_render") +# ── Final timing summary ────────────────────────────────────────────────────── +_total = _time.monotonic() - _t0 +print(f"[blender_render] TIMING_SUMMARY total={_total:.2f}s | " + + " | ".join(f"{k}={v:.2f}s" for k, v in _timings.items()), flush=True) print("[blender_render] Done.")