diff --git a/apps/editor/app/admin/page.tsx b/apps/editor/app/admin/page.tsx new file mode 100644 index 000000000..9203ad555 --- /dev/null +++ b/apps/editor/app/admin/page.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useState } from 'react' +import { AuditLogTab } from '@/components/admin/audit-log-tab' +import { CustomFieldsTab } from '@/components/admin/custom-fields-tab' +import { GroupsTab } from '@/components/admin/groups-tab' +import { PermissionsTab } from '@/components/admin/permissions-tab' +import { UsersTab } from '@/components/admin/users-tab' + +const TABS = [ + { id: 'users', label: 'Users' }, + { id: 'groups', label: 'Groups' }, + { id: 'permissions', label: 'Permissions' }, + { id: 'custom-fields', label: 'Custom Fields' }, + { id: 'audit-log', label: 'Audit Log' }, +] as const + +type TabId = (typeof TABS)[number]['id'] + +export default function AdminPage() { + const [activeTab, setActiveTab] = useState('users') + + return ( +
+
+
+

Admin Panel

+

+ Manage users, groups, permissions, custom fields, and audit logs. +

+
+ + {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} +
+ {activeTab === 'users' && } + {activeTab === 'groups' && } + {activeTab === 'permissions' && } + {activeTab === 'custom-fields' && } + {activeTab === 'audit-log' && } +
+
+
+ ) +} diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index 610eac4e6..6eec9fbd1 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -10,6 +10,9 @@ // idempotent under HMR. import '../lib/bootstrap' import { type ReactNode, useEffect } from 'react' +import { useAuth } from '@/store/use-auth' +import { usePermissions } from '@/store/use-permissions' +import { getSupabaseClient } from '@/lib/supabase' export function ClientBootstrap({ children }: { children: ReactNode }) { useEffect(() => { @@ -19,5 +22,27 @@ export function ClientBootstrap({ children }: { children: ReactNode }) { // is already a direct dep, so we don't need the CDN auto-global. import('react-scan').then(({ scan }) => scan({ enabled: true })) }, []) + + useEffect(() => { + // Initialize auth session and subscribe to changes. + useAuth.getState().init() + + // When auth state changes, reload permissions for the user's groups. + const sb = getSupabaseClient() + const { data: { subscription } } = sb.auth.onAuthStateChange(async (event, session) => { + if (session?.user) { + const { data: memberRows } = await sb + .from('group_members') + .select('group_id') + .eq('user_id', session.user.id) + const groupIds = (memberRows ?? []).map((r) => r.group_id) + await usePermissions.getState().loadForGroups(groupIds) + } else { + usePermissions.getState().clear() + } + }) + return () => { subscription.unsubscribe() } + }, []) + return children } diff --git a/apps/editor/app/login/page.tsx b/apps/editor/app/login/page.tsx new file mode 100644 index 000000000..f0211f28e --- /dev/null +++ b/apps/editor/app/login/page.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useRouter, useSearchParams } from 'next/navigation' +import { Suspense, useState } from 'react' +import { useAuth } from '@/store/use-auth' + +function LoginForm() { + const router = useRouter() + const params = useSearchParams() + const signIn = useAuth((s) => s.signIn) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const next = params.get('next') ?? '/' + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + const { error } = await signIn(email, password) + if (error) { + setError(error) + setLoading(false) + } else { + router.replace(next) + } + } + + return ( +
+
+
+

Sign in

+

Enter your credentials to continue

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + type="email" + value={email} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + type="password" + value={password} + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ) +} + +export default function LoginPage() { + return ( + + + + ) +} diff --git a/apps/editor/components/admin/audit-log-tab.tsx b/apps/editor/components/admin/audit-log-tab.tsx new file mode 100644 index 000000000..eebcac382 --- /dev/null +++ b/apps/editor/components/admin/audit-log-tab.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getSupabaseClient } from '@/lib/supabase' +import type { AuditLogEntry } from '@/lib/supabase-types' + +const PAGE_SIZE = 50 + +export function AuditLogTab() { + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState({ sceneId: '', userId: '', nodeKind: '', action: '' }) + const [page, setPage] = useState(0) + const [hasMore, setHasMore] = useState(false) + + const load = async (reset = true) => { + setLoading(true) + const sb = getSupabaseClient() + let q = sb + .from('audit_log') + .select('*') + .order('created_at', { ascending: false }) + .range(reset ? 0 : page * PAGE_SIZE, (reset ? 0 : page) * PAGE_SIZE + PAGE_SIZE - 1) + + if (filter.sceneId) q = q.eq('scene_id', filter.sceneId) + if (filter.userId) q = q.eq('user_id', filter.userId) + if (filter.nodeKind) q = q.eq('node_kind', filter.nodeKind) + if (filter.action) q = q.eq('action', filter.action as AuditLogEntry['action']) + + const { data } = await q + const rows = data ?? [] + if (reset) { + setEntries(rows) + setPage(0) + } else { + setEntries((prev) => [...prev, ...rows]) + } + setHasMore(rows.length === PAGE_SIZE) + setLoading(false) + } + + useEffect(() => { load(true) }, [filter]) + + const formatValue = (v: AuditLogEntry['old_value']): string => { + if (v === null || v === undefined) return '—' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) + } + + const actionColor = (action: string) => { + if (action === 'create') return 'text-green-400' + if (action === 'delete') return 'text-red-400' + return 'text-blue-400' + } + + return ( +
+ {/* Filters */} +
+ {[ + { key: 'sceneId', placeholder: 'Scene ID' }, + { key: 'nodeKind', placeholder: 'Node kind (e.g. wall)' }, + { key: 'userId', placeholder: 'User ID' }, + ].map(({ key, placeholder }) => ( + setFilter((prev) => ({ ...prev, [key]: e.target.value }))} + placeholder={placeholder} + value={filter[key as keyof typeof filter]} + /> + ))} + +
+ + {loading && entries.length === 0 ? ( +
Loading…
+ ) : entries.length === 0 ? ( +
No entries found.
+ ) : ( +
+ + + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + + ))} + +
WhenWhoActionNodeFieldBeforeAfter
+ {new Date(e.created_at).toLocaleString()} + {e.user_name} + {e.action} + + {e.node_kind} + {e.node_id.slice(0, 8)}… + {e.field_key ?? '—'} + {formatValue(e.old_value)} + + {formatValue(e.new_value)} +
+
+ )} + + {hasMore && ( + + )} +
+ ) +} diff --git a/apps/editor/components/admin/custom-fields-tab.tsx b/apps/editor/components/admin/custom-fields-tab.tsx new file mode 100644 index 000000000..6c8cbff9d --- /dev/null +++ b/apps/editor/components/admin/custom-fields-tab.tsx @@ -0,0 +1,248 @@ +'use client' + +import { nodeRegistry } from '@pascal-app/core' +import { useEffect, useMemo, useState } from 'react' +import { getSupabaseClient } from '@/lib/supabase' +import type { CustomField, CustomFieldPermission, Group } from '@/lib/supabase-types' + +export function CustomFieldsTab() { + const [fields, setFields] = useState([]) + const [groups, setGroups] = useState([]) + const [cfPerms, setCfPerms] = useState([]) + const [loading, setLoading] = useState(true) + + // Form state + const [newKey, setNewKey] = useState('') + const [newLabel, setNewLabel] = useState('') + const [newType, setNewType] = useState('text') + const [newKind, setNewKind] = useState('*') + const [newOptions, setNewOptions] = useState('') + const [newUnit, setNewUnit] = useState('') + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + + const nodeKinds = useMemo(() => { + const kinds = ['*'] + for (const [kind] of nodeRegistry.entries()) { + kinds.push(kind) + } + return kinds.sort() + }, []) + + const load = async () => { + const sb = getSupabaseClient() + const [fRes, gRes, cpRes] = await Promise.all([ + sb.from('custom_fields').select('*').order('sort_order').order('label'), + sb.from('groups').select('*').order('name'), + sb.from('custom_field_permissions').select('*'), + ]) + setFields(fRes.data ?? []) + setGroups(gRes.data ?? []) + setCfPerms(cpRes.data ?? []) + setLoading(false) + } + + useEffect(() => { load() }, []) + + const createField = async (e: React.FormEvent) => { + e.preventDefault() + setCreating(true) + setCreateError(null) + const sb = getSupabaseClient() + + const key = newKey.trim().toLowerCase().replace(/\s+/g, '_') + let options = null + if (newType === 'enum' && newOptions.trim()) { + options = newOptions.split(',').map((o) => o.trim()).filter(Boolean) + } + + const { data, error } = await sb + .from('custom_fields') + .insert({ + key, + label: newLabel.trim(), + field_type: newType, + node_kind: newKind, + options, + unit: newUnit.trim() || null, + sort_order: fields.length, + }) + .select() + .single() + + if (error) { + setCreateError(error.message) + } else if (data) { + setFields((prev) => [...prev, data]) + setNewKey('') + setNewLabel('') + setNewOptions('') + setNewUnit('') + } + setCreating(false) + } + + const deleteField = async (id: string) => { + if (!confirm('Delete this custom field?')) return + const sb = getSupabaseClient() + await sb.from('custom_fields').delete().eq('id', id) + setFields((prev) => prev.filter((f) => f.id !== id)) + } + + const toggleGroupPerm = async (fieldId: string, groupId: string) => { + const sb = getSupabaseClient() + const existing = cfPerms.find((p) => p.custom_field_id === fieldId && p.group_id === groupId) + if (existing) { + await sb.from('custom_field_permissions').delete().eq('id', existing.id) + setCfPerms((prev) => prev.filter((p) => p.id !== existing.id)) + } else { + const { data } = await sb + .from('custom_field_permissions') + .insert({ custom_field_id: fieldId, group_id: groupId, can_write: true }) + .select() + .single() + if (data) setCfPerms((prev) => [...prev, data]) + } + } + + if (loading) return
Loading…
+ + return ( +
+ {/* Create form */} +
+

New custom field

+
+
+ + { setNewLabel(e.target.value); if (!newKey) setNewKey(e.target.value) }} + placeholder="Nota Fiscal" + required + value={newLabel} + /> +
+
+ + setNewKey(e.target.value)} + placeholder="nota_fiscal" + required + value={newKey} + /> +
+
+ + +
+
+ + +
+ {newType === 'enum' && ( +
+ + setNewOptions(e.target.value)} + placeholder="opt1, opt2, opt3" + value={newOptions} + /> +
+ )} + {newType === 'number' && ( +
+ + setNewUnit(e.target.value)} + placeholder="m³/h" + value={newUnit} + /> +
+ )} + +
+ {createError &&

{createError}

} +
+ + {/* Fields list */} +
+ {fields.length === 0 && ( +

No custom fields yet.

+ )} + {fields.map((field) => ( +
+
+
+ {field.label} + {field.key} + + {field.field_type}{field.unit ? ` (${field.unit})` : ''} + + {field.node_kind === '*' ? 'All nodes' : field.node_kind} +
+ +
+
+

Groups with write access:

+
+ {groups.map((g) => { + const granted = cfPerms.some( + (p) => p.custom_field_id === field.id && p.group_id === g.id, + ) + return ( + + ) + })} +
+
+
+ ))} +
+
+ ) +} diff --git a/apps/editor/components/admin/groups-tab.tsx b/apps/editor/components/admin/groups-tab.tsx new file mode 100644 index 000000000..ab8bbb569 --- /dev/null +++ b/apps/editor/components/admin/groups-tab.tsx @@ -0,0 +1,183 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getSupabaseClient } from '@/lib/supabase' +import type { Group, UserProfile } from '@/lib/supabase-types' + +export function GroupsTab() { + const [groups, setGroups] = useState([]) + const [users, setUsers] = useState([]) + const [members, setMembers] = useState>({}) + const [loading, setLoading] = useState(true) + const [newName, setNewName] = useState('') + const [newDesc, setNewDesc] = useState('') + const [newColor, setNewColor] = useState('#6366f1') + const [creating, setCreating] = useState(false) + + const load = async () => { + const sb = getSupabaseClient() + const [gRes, uRes, mRes] = await Promise.all([ + sb.from('groups').select('*').order('name'), + sb.from('user_profiles').select('*').order('display_name'), + sb.from('group_members').select('*'), + ]) + setGroups(gRes.data ?? []) + setUsers(uRes.data ?? []) + + const mMap: Record = {} + for (const m of mRes.data ?? []) { + if (!mMap[m.group_id]) mMap[m.group_id] = [] + mMap[m.group_id]!.push(m.user_id) + } + setMembers(mMap) + setLoading(false) + } + + useEffect(() => { load() }, []) + + const createGroup = async (e: React.FormEvent) => { + e.preventDefault() + setCreating(true) + const sb = getSupabaseClient() + const { data } = await sb + .from('groups') + .insert({ name: newName, description: newDesc || null, color: newColor }) + .select() + .single() + if (data) { + setGroups((prev) => [...prev, data].sort((a, b) => a.name.localeCompare(b.name))) + setNewName('') + setNewDesc('') + } + setCreating(false) + } + + const deleteGroup = async (id: string) => { + if (!confirm('Delete this group? All permissions will be removed.')) return + const sb = getSupabaseClient() + await sb.from('groups').delete().eq('id', id) + setGroups((prev) => prev.filter((g) => g.id !== id)) + setMembers((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + } + + const toggleMember = async (groupId: string, userId: string) => { + const sb = getSupabaseClient() + const current = members[groupId] ?? [] + const isMember = current.includes(userId) + + if (isMember) { + await sb.from('group_members').delete().eq('group_id', groupId).eq('user_id', userId) + setMembers((prev) => ({ ...prev, [groupId]: (prev[groupId] ?? []).filter((id) => id !== userId) })) + } else { + await sb.from('group_members').insert({ group_id: groupId, user_id: userId }) + setMembers((prev) => ({ ...prev, [groupId]: [...(prev[groupId] ?? []), userId] })) + } + } + + if (loading) return
Loading…
+ + return ( +
+ {/* Create group */} +
+

New group

+
+
+ + setNewName(e.target.value)} + placeholder="Hydraulics" + required + value={newName} + /> +
+
+ + setNewDesc(e.target.value)} + placeholder="Optional description" + value={newDesc} + /> +
+
+ + setNewColor(e.target.value)} + type="color" + value={newColor} + /> +
+ +
+
+ + {/* Groups list */} +
+ {groups.length === 0 && ( +

No groups yet.

+ )} + {groups.map((group) => { + const groupMembers = members[group.id] ?? [] + return ( +
+
+
+ + {group.name} + {group.description && ( + — {group.description} + )} +
+ +
+
+ {users.map((user) => { + const isMember = groupMembers.includes(user.id) + return ( + + ) + })} + {users.length === 0 && ( + No users yet + )} +
+
+ ) + })} +
+
+ ) +} diff --git a/apps/editor/components/admin/permissions-tab.tsx b/apps/editor/components/admin/permissions-tab.tsx new file mode 100644 index 000000000..cf26ecb05 --- /dev/null +++ b/apps/editor/components/admin/permissions-tab.tsx @@ -0,0 +1,203 @@ +'use client' + +import { nodeRegistry } from '@pascal-app/core' +import { useEffect, useMemo, useState } from 'react' +import { getSupabaseClient } from '@/lib/supabase' +import type { Group, ParameterPermission } from '@/lib/supabase-types' + +export function PermissionsTab() { + const [groups, setGroups] = useState([]) + const [permissions, setPermissions] = useState([]) + const [selectedGroup, setSelectedGroup] = useState(null) + const [loading, setLoading] = useState(true) + + // Collect all node kinds + parametric fields from the registry + const nodeKinds = useMemo(() => { + const kinds: Array<{ + kind: string + label: string + fields: Array<{ key: string; label: string }> + }> = [] + for (const [kind, def] of nodeRegistry.entries()) { + const parametrics = def.parametrics + if (!parametrics) continue + const fields = parametrics.groups.flatMap((g) => + g.fields.map((f) => ({ + key: String(f.key), + label: prettify(String(f.key)), + })), + ) + if (fields.length === 0) continue + kinds.push({ + kind, + label: def.presentation?.label ?? kind, + fields, + }) + } + return kinds.sort((a, b) => a.label.localeCompare(b.label)) + }, []) + + const load = async () => { + const sb = getSupabaseClient() + const [gRes, pRes] = await Promise.all([ + sb.from('groups').select('*').order('name'), + sb.from('parameter_permissions').select('*'), + ]) + const gs = gRes.data ?? [] + setGroups(gs) + setPermissions(pRes.data ?? []) + if (gs.length > 0 && !selectedGroup) setSelectedGroup(gs[0]!.id) + setLoading(false) + } + + useEffect(() => { load() }, []) + + const isGranted = (nodeKind: string, paramKey: string) => { + if (!selectedGroup) return false + return permissions.some( + (p) => + p.group_id === selectedGroup && + (p.node_kind === nodeKind || p.node_kind === '*') && + (p.parameter_key === paramKey || p.parameter_key === '*') && + p.can_write, + ) + } + + const toggle = async (nodeKind: string, paramKey: string) => { + if (!selectedGroup) return + const sb = getSupabaseClient() + const existing = permissions.find( + (p) => p.group_id === selectedGroup && p.node_kind === nodeKind && p.parameter_key === paramKey, + ) + + if (existing) { + const newVal = !existing.can_write + await sb.from('parameter_permissions').update({ can_write: newVal }).eq('id', existing.id) + setPermissions((prev) => + prev.map((p) => (p.id === existing.id ? { ...p, can_write: newVal } : p)), + ) + } else { + const { data } = await sb + .from('parameter_permissions') + .insert({ group_id: selectedGroup, node_kind: nodeKind, parameter_key: paramKey, can_write: true }) + .select() + .single() + if (data) setPermissions((prev) => [...prev, data]) + } + } + + const toggleAllForKind = async (nodeKind: string, fields: string[]) => { + if (!selectedGroup) return + const allGranted = fields.every((f) => isGranted(nodeKind, f)) + const sb = getSupabaseClient() + + if (allGranted) { + // Revoke all + await sb + .from('parameter_permissions') + .delete() + .eq('group_id', selectedGroup) + .eq('node_kind', nodeKind) + setPermissions((prev) => + prev.filter((p) => !(p.group_id === selectedGroup && p.node_kind === nodeKind)), + ) + } else { + // Grant all not yet granted + const toAdd = fields + .filter((f) => !isGranted(nodeKind, f)) + .map((f) => ({ group_id: selectedGroup, node_kind: nodeKind, parameter_key: f, can_write: true })) + if (toAdd.length > 0) { + const { data } = await sb.from('parameter_permissions').insert(toAdd).select() + if (data) setPermissions((prev) => [...prev, ...data]) + } + } + } + + if (loading) return
Loading…
+ if (groups.length === 0) return ( +

+ Create a group first in the Groups tab. +

+ ) + + return ( +
+ {/* Group selector sidebar */} +
+

Group

+
+ {groups.map((g) => ( + + ))} +
+
+ + {/* Permissions matrix */} +
+ {nodeKinds.length === 0 && ( +

No node kinds with parametrics found.

+ )} +
+ {nodeKinds.map(({ kind, label, fields }) => { + const allGranted = fields.every((f) => isGranted(kind, f.key)) + return ( +
+
+ {label} + +
+
+ {fields.map((field) => { + const granted = isGranted(kind, field.key) + return ( + + ) + })} +
+
+ ) + })} +
+
+
+ ) +} + +function prettify(key: string): string { + return key + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[-_]/g, ' ') + .replace(/^\w/, (c) => c.toUpperCase()) +} diff --git a/apps/editor/components/admin/users-tab.tsx b/apps/editor/components/admin/users-tab.tsx new file mode 100644 index 000000000..f34994bcc --- /dev/null +++ b/apps/editor/components/admin/users-tab.tsx @@ -0,0 +1,192 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getSupabaseClient } from '@/lib/supabase' +import type { Group, UserProfile } from '@/lib/supabase-types' +import { cn } from '@/lib/utils' + +type UserWithProfile = UserProfile & { email: string; groups: string[] } + +export function UsersTab() { + const [users, setUsers] = useState([]) + const [groups, setGroups] = useState([]) + const [loading, setLoading] = useState(true) + const [inviteEmail, setInviteEmail] = useState('') + const [inviteRole, setInviteRole] = useState<'admin' | 'editor' | 'viewer'>('viewer') + const [inviting, setInviting] = useState(false) + const [inviteError, setInviteError] = useState(null) + const [inviteSuccess, setInviteSuccess] = useState(false) + + const load = async () => { + const sb = getSupabaseClient() + const [profilesRes, groupsRes, membersRes] = await Promise.all([ + sb.from('user_profiles').select('*').order('created_at'), + sb.from('groups').select('*').order('name'), + sb.from('group_members').select('*'), + ]) + const profiles = profilesRes.data ?? [] + const allGroups = groupsRes.data ?? [] + const members = membersRes.data ?? [] + setGroups(allGroups) + + // Fetch emails via admin API (only works if you have service-role key) + // For MVP, display_name + id is enough. Email can be fetched via admin.listUsers + // if SUPABASE_SERVICE_ROLE_KEY is set server-side. For now just show profile. + const userList: UserWithProfile[] = profiles.map((p) => ({ + ...p, + email: '', + groups: members.filter((m) => m.user_id === p.id).map((m) => { + const g = allGroups.find((g) => g.id === m.group_id) + return g?.name ?? m.group_id + }), + })) + setUsers(userList) + setLoading(false) + } + + useEffect(() => { load() }, []) + + const updateRole = async (userId: string, role: 'admin' | 'editor' | 'viewer') => { + const sb = getSupabaseClient() + await sb.from('user_profiles').update({ role }).eq('id', userId) + setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role } : u))) + } + + const toggleActive = async (userId: string, current: boolean) => { + const sb = getSupabaseClient() + await sb.from('user_profiles').update({ is_active: !current }).eq('id', userId) + setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, is_active: !current } : u))) + } + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault() + setInviting(true) + setInviteError(null) + // Use Supabase Admin API via a server action / API route in production. + // For MVP, call the Supabase Auth admin.inviteUserByEmail endpoint via the client. + const sb = getSupabaseClient() + const { error } = await (sb.auth.admin as unknown as { + inviteUserByEmail: (email: string, opts: unknown) => Promise<{ error: { message: string } | null }> + }).inviteUserByEmail(inviteEmail, { data: { role: inviteRole } }).catch(() => ({ + error: { message: 'Invite requires service-role key on the server. Use Supabase Dashboard → Authentication → Users → Invite.' }, + })) + if (error) { + setInviteError(error.message) + } else { + setInviteSuccess(true) + setInviteEmail('') + setTimeout(() => setInviteSuccess(false), 3000) + } + setInviting(false) + } + + if (loading) return
Loading…
+ + return ( +
+ {/* Invite form */} +
+

Invite user

+
+
+ + setInviteEmail(e.target.value)} + placeholder="user@example.com" + required + type="email" + value={inviteEmail} + /> +
+
+ + +
+ +
+ {inviteError &&

{inviteError}

} + {inviteSuccess &&

Invite sent!

} +
+ + {/* Users table */} +
+ + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
NameGroupsRoleStatus
+ {user.display_name || '—'} + {user.id.slice(0, 8)}… + +
+ {user.groups.length === 0 ? ( + No group + ) : ( + user.groups.map((g) => ( + + {g} + + )) + )} +
+
+ + + +
+
+
+ ) +} diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 0ea0b3553..566abefc7 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -45,6 +45,9 @@ type MepToolKind = | 'pipe-segment' | 'pipe-fitting' | 'pipe-trap' + | 'water-line' + | 'electrical-conduit' + | 'electrical-device' type BuildType = { /** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */ @@ -85,9 +88,8 @@ const BUILD_TYPES: BuildType[] = [ { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, ] -// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP -// tools had in the community Build sidebar. -const MEP_ITEMS: MepItem[] = [ +// MEP sub-grid items grouped by discipline. +const MEP_HVAC_ITEMS: MepItem[] = [ { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, { id: 'duct-terminal', @@ -98,9 +100,31 @@ const MEP_ITEMS: MepItem[] = [ { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, +] +const MEP_PLUMBING_ITEMS: MepItem[] = [ + { id: 'water-line', label: 'Water Line', iconSrc: '/icons/dwv-pipes.webp', kind: 'water-line' }, { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, { id: 'pipe-trap', label: 'Trap', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-trap' }, ] +const MEP_ELECTRICAL_ITEMS: MepItem[] = [ + { + id: 'electrical-conduit', + label: 'Conduit', + iconSrc: '/icons/HVAC.webp', + kind: 'electrical-conduit', + }, + { + id: 'electrical-device', + label: 'Device', + iconSrc: '/icons/HVAC.webp', + kind: 'electrical-device', + }, +] +const MEP_ITEMS: MepItem[] = [ + ...MEP_HVAC_ITEMS, + ...MEP_PLUMBING_ITEMS, + ...MEP_ELECTRICAL_ITEMS, +] /** * Activate a raw structure draw/cursor tool. Mirrors the editor's own @@ -345,49 +369,59 @@ export function BuildTab() { ) : isMepActive ? ( -
-
MEP
- -
- {MEP_ITEMS.map((item) => { - const active = isMepItemActive(item) - return ( - - - - - - {item.label} - - - ) - })} +
+ {( + [ + { label: 'HVAC', items: MEP_HVAC_ITEMS }, + { label: 'Plumbing', items: MEP_PLUMBING_ITEMS }, + { label: 'Electrical', items: MEP_ELECTRICAL_ITEMS }, + ] as const + ).map(({ label, items }) => ( +
+
{label}
+ +
+ {items.map((item) => { + const active = isMepItemActive(item) + return ( + + + + + + {item.label} + + + ) + })} +
+
- + ))} {ductContext ? (
diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 34acd2e2d..a831c9284 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -14,6 +14,7 @@ import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' +import { useAuditLogger } from '@/hooks/use-audit-logger' import { BuildTab } from './build-tab' import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar' @@ -92,6 +93,7 @@ function sceneGraphSignature(graph: SceneGraphWithCollections): string { } export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { + useAuditLogger(meta.id) const router = useRouter() const versionRef = useRef(meta.version) const lastRemoteGraphJsonRef = useRef(null) diff --git a/apps/editor/hooks/use-audit-logger.ts b/apps/editor/hooks/use-audit-logger.ts new file mode 100644 index 000000000..2222b2735 --- /dev/null +++ b/apps/editor/hooks/use-audit-logger.ts @@ -0,0 +1,105 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useEffect } from 'react' +import { getSupabaseClient } from '@/lib/supabase' +import type { Database } from '@/lib/supabase-types' +import { useAuth } from '@/store/use-auth' + +type AuditInsert = Database['public']['Tables']['audit_log']['Insert'] + +/** + * Subscribes to useScene and logs node create/update/delete to the + * Supabase audit_log table. Mount once in the scene page. + */ +export function useAuditLogger(sceneId: string) { + useEffect(() => { + const unsubscribe = useScene.subscribe((state, prevState) => { + const { user, profile } = useAuth.getState() + if (!user) return + + const userName = profile?.display_name || user.email || 'Unknown' + const curr = state.nodes as Record + const prev = prevState.nodes as Record + const sb = getSupabaseClient() + + const entries: AuditInsert[] = [] + + // Detect creations + for (const id of Object.keys(curr)) { + if (!prev[id]) { + const node = curr[id] as Record + entries.push({ + user_id: user.id, + user_name: userName, + scene_id: sceneId, + node_id: id, + node_kind: String(node['type'] ?? 'unknown'), + node_label: null, + action: 'create', + field_key: null, + field_label: null, + old_value: null, + new_value: node as unknown as AuditInsert['new_value'], + }) + } + } + + // Detect deletions + for (const id of Object.keys(prev)) { + if (!curr[id]) { + const node = prev[id] as Record + entries.push({ + user_id: user.id, + user_name: userName, + scene_id: sceneId, + node_id: id, + node_kind: String(node['type'] ?? 'unknown'), + node_label: null, + action: 'delete', + field_key: null, + field_label: null, + old_value: node as unknown as AuditInsert['old_value'], + new_value: null, + }) + } + } + + // Detect field updates + for (const id of Object.keys(curr)) { + if (!prev[id]) continue // already logged as create + const currNode = curr[id] as Record + const prevNode = prev[id] as Record + if (currNode === prevNode) continue + + for (const key of Object.keys(currNode)) { + if (key === 'id' || key === 'type' || key === 'object') continue + const oldVal = prevNode[key] + const newVal = currNode[key] + if (JSON.stringify(oldVal) === JSON.stringify(newVal)) continue + + entries.push({ + user_id: user.id, + user_name: userName, + scene_id: sceneId, + node_id: id, + node_kind: String(currNode['type'] ?? 'unknown'), + node_label: null, + action: 'update', + field_key: key, + field_label: null, + old_value: (oldVal ?? null) as AuditInsert['old_value'], + new_value: (newVal ?? null) as AuditInsert['new_value'], + }) + } + } + + if (entries.length > 0) { + // Fire-and-forget: don't block the render on audit writes + sb.from('audit_log').insert(entries).then(() => {}) + } + }) + + return unsubscribe + }, [sceneId]) +} diff --git a/apps/editor/lib/supabase-server.ts b/apps/editor/lib/supabase-server.ts new file mode 100644 index 000000000..1dec62af8 --- /dev/null +++ b/apps/editor/lib/supabase-server.ts @@ -0,0 +1,24 @@ +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' +import type { Database } from './supabase-types' + +/** Server-side Supabase client for Next.js App Router (reads cookies). */ +export async function getSupabaseServerClient() { + const cookieStore = await cookies() + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll: () => cookieStore.getAll(), + setAll: (toSet) => { + try { + for (const { name, value, options } of toSet) { + cookieStore.set(name, value, options) + } + } catch {} + }, + }, + }, + ) +} diff --git a/apps/editor/lib/supabase-types.ts b/apps/editor/lib/supabase-types.ts new file mode 100644 index 000000000..3cd39d883 --- /dev/null +++ b/apps/editor/lib/supabase-types.ts @@ -0,0 +1,211 @@ +/** Minimal hand-written types for the permission schema. + * Replace with generated types (`supabase gen types typescript`) once + * the project is linked to a Supabase project. */ + +export type Json = string | number | boolean | null | { [key: string]: Json } | Json[] + +export interface Database { + public: { + Tables: { + user_profiles: { + Row: { + id: string + display_name: string + role: 'admin' | 'editor' | 'viewer' + is_active: boolean + created_at: string + updated_at: string + } + Insert: { + id: string + display_name?: string + role?: 'admin' | 'editor' | 'viewer' + is_active?: boolean + created_at?: string + updated_at?: string + } + Update: { + display_name?: string + role?: 'admin' | 'editor' | 'viewer' + is_active?: boolean + updated_at?: string + } + Relationships: [] + } + groups: { + Row: { + id: string + name: string + description: string | null + color: string | null + created_at: string + updated_at: string + } + Insert: { + id?: string + name: string + description?: string | null + color?: string | null + created_at?: string + updated_at?: string + } + Update: { + name?: string + description?: string | null + color?: string | null + updated_at?: string + } + Relationships: [] + } + group_members: { + Row: { + id: string + group_id: string + user_id: string + created_at: string + } + Insert: { + id?: string + group_id: string + user_id: string + created_at?: string + } + Update: { + group_id?: string + user_id?: string + } + Relationships: [] + } + parameter_permissions: { + Row: { + id: string + group_id: string + node_kind: string + parameter_key: string + can_write: boolean + created_at: string + } + Insert: { + id?: string + group_id: string + node_kind: string + parameter_key: string + can_write?: boolean + created_at?: string + } + Update: { + can_write?: boolean + } + Relationships: [] + } + custom_fields: { + Row: { + id: string + key: string + label: string + field_type: 'text' | 'number' | 'date' | 'boolean' | 'enum' + node_kind: string + options: Json | null + unit: string | null + required: boolean + sort_order: number + created_at: string + } + Insert: { + id?: string + key: string + label: string + field_type: 'text' | 'number' | 'date' | 'boolean' | 'enum' + node_kind?: string + options?: Json | null + unit?: string | null + required?: boolean + sort_order?: number + created_at?: string + } + Update: { + label?: string + field_type?: 'text' | 'number' | 'date' | 'boolean' | 'enum' + node_kind?: string + options?: Json | null + unit?: string | null + required?: boolean + sort_order?: number + } + Relationships: [] + } + custom_field_permissions: { + Row: { + id: string + custom_field_id: string + group_id: string + can_write: boolean + } + Insert: { + id?: string + custom_field_id: string + group_id: string + can_write?: boolean + } + Update: { + can_write?: boolean + } + Relationships: [] + } + audit_log: { + Row: { + id: string + user_id: string | null + user_name: string + scene_id: string + node_id: string + node_kind: string + node_label: string | null + action: 'create' | 'update' | 'delete' + field_key: string | null + field_label: string | null + old_value: Json | null + new_value: Json | null + created_at: string + } + Insert: { + id?: string + user_id?: string | null + user_name: string + scene_id: string + node_id: string + node_kind: string + node_label?: string | null + action: 'create' | 'update' | 'delete' + field_key?: string | null + field_label?: string | null + old_value?: Json | null + new_value?: Json | null + created_at?: string + } + Update: never + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + is_admin: { Args: Record; Returns: boolean } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +export type UserProfile = Database['public']['Tables']['user_profiles']['Row'] +export type Group = Database['public']['Tables']['groups']['Row'] +export type GroupMember = Database['public']['Tables']['group_members']['Row'] +export type ParameterPermission = Database['public']['Tables']['parameter_permissions']['Row'] +export type CustomField = Database['public']['Tables']['custom_fields']['Row'] +export type CustomFieldPermission = Database['public']['Tables']['custom_field_permissions']['Row'] +export type AuditLogEntry = Database['public']['Tables']['audit_log']['Row'] diff --git a/apps/editor/lib/supabase.ts b/apps/editor/lib/supabase.ts new file mode 100644 index 000000000..7eab9bd07 --- /dev/null +++ b/apps/editor/lib/supabase.ts @@ -0,0 +1,17 @@ +'use client' + +import { createBrowserClient } from '@supabase/ssr' +import type { Database } from './supabase-types' + +let client: ReturnType> | null = null + +/** Browser-side Supabase singleton. Safe to call multiple times. */ +export function getSupabaseClient() { + if (!client) { + client = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ) + } + return client +} diff --git a/apps/editor/middleware.ts b/apps/editor/middleware.ts new file mode 100644 index 000000000..0e9c28647 --- /dev/null +++ b/apps/editor/middleware.ts @@ -0,0 +1,57 @@ +import { createServerClient } from '@supabase/ssr' +import { type NextRequest, NextResponse } from 'next/server' + +const PUBLIC_PATHS = ['/login', '/privacy', '/terms', '/api'] + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // Skip middleware for public paths + if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { + return NextResponse.next() + } + + const response = NextResponse.next() + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll: () => request.cookies.getAll(), + setAll: (toSet) => { + for (const { name, value, options } of toSet) { + request.cookies.set(name, value) + response.cookies.set(name, value, options) + } + }, + }, + }, + ) + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + const loginUrl = new URL('/login', request.url) + loginUrl.searchParams.set('next', pathname) + return NextResponse.redirect(loginUrl) + } + + // Admin-only route guard + if (pathname.startsWith('/admin')) { + const { data: profile } = await supabase + .from('user_profiles') + .select('role') + .eq('id', user.id) + .single() + if (profile?.role !== 'admin') { + return NextResponse.redirect(new URL('/', request.url)) + } + } + + return response +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|icons|fonts|.*\\.webp|.*\\.png|.*\\.svg).*)'], +} diff --git a/apps/editor/package.json b/apps/editor/package.json index 5af999759..df5adc256 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,6 +21,8 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@supabase/ssr": "^0.12.0", + "@supabase/supabase-js": "^2.110.0", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "geist": "^1.7.0", @@ -32,7 +34,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "three": "^0.184.0", - "zod": "^4.3.5" + "zod": "^4.3.5", + "zustand": "^5.0.14" }, "devDependencies": { "@pascal/typescript-config": "*", diff --git a/apps/editor/store/use-auth.ts b/apps/editor/store/use-auth.ts new file mode 100644 index 000000000..20e389c0f --- /dev/null +++ b/apps/editor/store/use-auth.ts @@ -0,0 +1,70 @@ +'use client' + +import type { User } from '@supabase/supabase-js' +import { create } from 'zustand' +import { getSupabaseClient } from '@/lib/supabase' +import type { UserProfile } from '@/lib/supabase-types' + +interface AuthState { + user: User | null + profile: UserProfile | null + loading: boolean + /** Call once from a client boundary (e.g. ClientBootstrap). */ + init: () => Promise + signIn: (email: string, password: string) => Promise<{ error: string | null }> + signOut: () => Promise + isAdmin: () => boolean +} + +export const useAuth = create((set, get) => ({ + user: null, + profile: null, + loading: true, + + init: async () => { + const sb = getSupabaseClient() + + // Restore existing session + const { data: { session } } = await sb.auth.getSession() + if (session?.user) { + const profile = await fetchProfile(session.user.id) + set({ user: session.user, profile, loading: false }) + } else { + set({ loading: false }) + } + + // Listen for auth state changes + sb.auth.onAuthStateChange(async (_event, session) => { + if (session?.user) { + const profile = await fetchProfile(session.user.id) + set({ user: session.user, profile }) + } else { + set({ user: null, profile: null }) + } + }) + }, + + signIn: async (email, password) => { + const sb = getSupabaseClient() + const { error } = await sb.auth.signInWithPassword({ email, password }) + return { error: error?.message ?? null } + }, + + signOut: async () => { + const sb = getSupabaseClient() + await sb.auth.signOut() + set({ user: null, profile: null }) + }, + + isAdmin: () => get().profile?.role === 'admin', +})) + +async function fetchProfile(userId: string): Promise { + const sb = getSupabaseClient() + const { data } = await sb + .from('user_profiles') + .select('*') + .eq('id', userId) + .single() + return data ?? null +} diff --git a/apps/editor/store/use-permissions.ts b/apps/editor/store/use-permissions.ts new file mode 100644 index 000000000..aec81ceb8 --- /dev/null +++ b/apps/editor/store/use-permissions.ts @@ -0,0 +1,80 @@ +'use client' + +import { create } from 'zustand' +import { getSupabaseClient } from '@/lib/supabase' +import type { CustomField, ParameterPermission } from '@/lib/supabase-types' + +interface PermissionsState { + /** Flattened set of "nodeKind:paramKey" → writable. Loaded from Supabase. */ + writableFields: Set + /** Custom fields visible to this user (can be read by anyone in their groups). */ + customFields: CustomField[] + /** Custom field IDs the user can write to. */ + writableCustomFields: Set + loaded: boolean + + /** Load permissions for a list of group IDs (called after auth). */ + loadForGroups: (groupIds: string[]) => Promise + clear: () => void + + /** Returns true if the current user can write this field on this node kind. + * Admin bypass handled by the caller checking useAuth().isAdmin(). */ + canWrite: (nodeKind: string, fieldKey: string) => boolean + canWriteCustomField: (customFieldId: string) => boolean +} + +export const usePermissions = create((set, get) => ({ + writableFields: new Set(), + customFields: [], + writableCustomFields: new Set(), + loaded: false, + + loadForGroups: async (groupIds) => { + if (!groupIds.length) { + set({ writableFields: new Set(), customFields: [], writableCustomFields: new Set(), loaded: true }) + return + } + + const sb = getSupabaseClient() + + const [permsRes, cfRes, cfPermRes] = await Promise.all([ + sb.from('parameter_permissions').select('*').in('group_id', groupIds), + sb.from('custom_fields').select('*').order('sort_order'), + sb.from('custom_field_permissions').select('*').in('group_id', groupIds), + ]) + + const perms: ParameterPermission[] = permsRes.data ?? [] + const allCustomFields: CustomField[] = cfRes.data ?? [] + const cfPerms = cfPermRes.data ?? [] + + const writableFields = new Set() + for (const p of perms) { + if (p.can_write) { + writableFields.add(`${p.node_kind}:${p.parameter_key}`) + // '*' means all fields of this kind + if (p.parameter_key === '*') writableFields.add(`${p.node_kind}:*`) + } + } + + const writableCustomFields = new Set( + cfPerms.filter((p) => p.can_write).map((p) => p.custom_field_id), + ) + + set({ writableFields, customFields: allCustomFields, writableCustomFields, loaded: true }) + }, + + clear: () => + set({ writableFields: new Set(), customFields: [], writableCustomFields: new Set(), loaded: false }), + + canWrite: (nodeKind, fieldKey) => { + const { writableFields } = get() + return ( + writableFields.has(`${nodeKind}:${fieldKey}`) || + writableFields.has(`${nodeKind}:*`) || + writableFields.has(`*:${fieldKey}`) || + writableFields.has('*:*') + ) + }, + + canWriteCustomField: (customFieldId) => get().writableCustomFields.has(customFieldId), +})) diff --git a/apps/editor/supabase/schema.sql b/apps/editor/supabase/schema.sql new file mode 100644 index 000000000..3251bd6d1 --- /dev/null +++ b/apps/editor/supabase/schema.sql @@ -0,0 +1,197 @@ +-- ============================================================= +-- Pascal Editor — Permission & Audit System +-- Run once in Supabase SQL Editor (Dashboard → SQL Editor) +-- ============================================================= + +-- ── 1. User Profiles ───────────────────────────────────────── +-- Extends auth.users with display name and role. +CREATE TABLE IF NOT EXISTS public.user_profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'viewer' + CHECK (role IN ('admin', 'editor', 'viewer')), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Auto-create profile on signup +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + INSERT INTO public.user_profiles (id, display_name) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email, '@', 1)) + ); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ── 2. Groups / Departments ─────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + color TEXT DEFAULT '#6366f1', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── 3. Group Members ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.group_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES public.groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (group_id, user_id) +); + +-- ── 4. Parameter Permissions ────────────────────────────────── +-- Which parametric fields a group can WRITE to, per node kind. +-- Use node_kind = '*' to apply to all kinds. +-- Use parameter_key = '*' to allow all fields of a kind. +CREATE TABLE IF NOT EXISTS public.parameter_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES public.groups(id) ON DELETE CASCADE, + node_kind TEXT NOT NULL, + parameter_key TEXT NOT NULL, + can_write BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (group_id, node_kind, parameter_key) +); + +CREATE INDEX IF NOT EXISTS parameter_permissions_group_kind_idx + ON public.parameter_permissions(group_id, node_kind); + +-- ── 5. Custom Fields ───────────────────────────────────────── +-- Admin-defined fields that extend node.metadata. +CREATE TABLE IF NOT EXISTS public.custom_fields ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + field_type TEXT NOT NULL DEFAULT 'text' + CHECK (field_type IN ('text', 'number', 'date', 'boolean', 'enum')), + node_kind TEXT NOT NULL DEFAULT '*', + options JSONB, + unit TEXT, + required BOOLEAN NOT NULL DEFAULT false, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS custom_fields_node_kind_idx + ON public.custom_fields(node_kind); + +-- ── 6. Custom Field Permissions ────────────────────────────── +-- Which groups can write to which custom fields. +CREATE TABLE IF NOT EXISTS public.custom_field_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + custom_field_id UUID NOT NULL REFERENCES public.custom_fields(id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES public.groups(id) ON DELETE CASCADE, + can_write BOOLEAN NOT NULL DEFAULT true, + UNIQUE (custom_field_id, group_id) +); + +-- ── 7. Audit Log ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + user_name TEXT NOT NULL DEFAULT '', + scene_id TEXT NOT NULL, + node_id TEXT NOT NULL, + node_kind TEXT NOT NULL, + node_label TEXT, + action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), + field_key TEXT, + field_label TEXT, + old_value JSONB, + new_value JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS audit_log_scene_node_idx + ON public.audit_log(scene_id, node_id, created_at DESC); +CREATE INDEX IF NOT EXISTS audit_log_user_idx + ON public.audit_log(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS audit_log_created_at_idx + ON public.audit_log(created_at DESC); + +-- ============================================================= +-- Row-Level Security +-- ============================================================= + +ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.groups ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.group_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.parameter_permissions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.custom_fields ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.custom_field_permissions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY; + +-- Helper: is the current user an admin? +CREATE OR REPLACE FUNCTION public.is_admin() +RETURNS BOOLEAN LANGUAGE sql STABLE SECURITY DEFINER AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.user_profiles + WHERE id = auth.uid() AND role = 'admin' + ); +$$; + +-- user_profiles: anyone reads; self can update own name; admin does everything +CREATE POLICY "users read all profiles" + ON public.user_profiles FOR SELECT TO authenticated USING (true); +CREATE POLICY "users update own profile" + ON public.user_profiles FOR UPDATE TO authenticated + USING (id = auth.uid()) WITH CHECK (id = auth.uid()); +CREATE POLICY "admin manages profiles" + ON public.user_profiles FOR ALL TO authenticated + USING (public.is_admin()) WITH CHECK (public.is_admin()); + +-- groups: all authenticated can read; only admin writes +CREATE POLICY "authenticated read groups" + ON public.groups FOR SELECT TO authenticated USING (true); +CREATE POLICY "admin manages groups" + ON public.groups FOR ALL TO authenticated + USING (public.is_admin()) WITH CHECK (public.is_admin()); + +-- group_members: all authenticated can read; only admin writes +CREATE POLICY "authenticated read group members" + ON public.group_members FOR SELECT TO authenticated USING (true); +CREATE POLICY "admin manages group members" + ON public.group_members FOR ALL TO authenticated + USING (public.is_admin()) WITH CHECK (public.is_admin()); + +-- parameter_permissions: all authenticated can read; only admin writes +CREATE POLICY "authenticated read parameter permissions" + ON public.parameter_permissions FOR SELECT TO authenticated USING (true); +CREATE POLICY "admin manages parameter permissions" + ON public.parameter_permissions FOR ALL TO authenticated + USING (public.is_admin()) WITH CHECK (public.is_admin()); + +-- custom_fields: all authenticated can read; only admin writes +CREATE POLICY "authenticated read custom fields" + ON public.custom_fields FOR SELECT TO authenticated USING (true); +CREATE POLICY "admin manages custom fields" + ON public.custom_fields FOR ALL TO authenticated + USING (public.is_admin()) WITH CHECK (public.is_admin()); + +-- custom_field_permissions: all authenticated can read; only admin writes +CREATE POLICY "authenticated read custom field permissions" + ON public.custom_field_permissions FOR SELECT TO authenticated USING (true); +CREATE POLICY "admin manages custom field permissions" + ON public.custom_field_permissions FOR ALL TO authenticated + USING (public.is_admin()) WITH CHECK (public.is_admin()); + +-- audit_log: admin reads all; authenticated users insert own entries +CREATE POLICY "admin reads audit log" + ON public.audit_log FOR SELECT TO authenticated + USING (public.is_admin()); +CREATE POLICY "authenticated inserts audit log" + ON public.audit_log FOR INSERT TO authenticated + WITH CHECK (user_id = auth.uid()); diff --git a/bun.lock b/bun.lock index 3eb9cb4e4..ece8d98f1 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,8 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@supabase/ssr": "^0.12.0", + "@supabase/supabase-js": "^2.110.0", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "geist": "^1.7.0", @@ -49,6 +51,7 @@ "tailwindcss": "^4.2.1", "three": "^0.184.0", "zod": "^4.3.5", + "zustand": "^5.0.14", }, "devDependencies": { "@pascal/typescript-config": "*", @@ -795,6 +798,22 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@supabase/auth-js": ["@supabase/auth-js@2.110.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.110.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.4", "", {}, "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.110.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ZbC1QZL3jcvBUfVKjJbgRM27G4Mg3Zzqdm44m5pJafe1e52Cli793EOnwQucomBAGEUDd03Nzaf7XV3ji/XexQ=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.110.0", "", { "dependencies": { "@supabase/phoenix": "0.4.4", "tslib": "2.8.1" } }, "sha512-Wn2AWpneZuDFTkp/65tqctvoh+3JvyTjMam8sTMqVWy5BgkU8zAvFwilPYPPPhkINeKF8NAJKP7FclJ2iGCUMw=="], + + "@supabase/ssr": ["@supabase/ssr@0.12.0", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.108.0" } }, "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.110.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-71+gU3HrhiylAhftY6FmO5PPdcsScnVcS766CVD+vTYK9qTDLbrx8FhgBYbqGm3iV/wkTfzrNJfjGsMeFRkJRQ=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.110.0", "", { "dependencies": { "@supabase/auth-js": "2.110.0", "@supabase/functions-js": "2.110.0", "@supabase/postgrest-js": "2.110.0", "@supabase/realtime-js": "2.110.0", "@supabase/storage-js": "2.110.0" } }, "sha512-8yI84VJiEVW4zxZpLUmxXmjzQ7O2St9X/ymzlBETDHTURPWG3LmvbSiibq+7dqAJmyoUfxZnSfXeM4HCM8s4XQ=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], @@ -1043,7 +1062,7 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -1297,6 +1316,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "idb-keyval": ["idb-keyval@6.2.5", "", {}, "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q=="], @@ -1965,6 +1986,8 @@ "eslint-plugin-turbo/dotenv": ["dotenv@16.0.3", "", {}, "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6374cbb40..66869e535 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -14,6 +14,8 @@ import type { DuctFittingNode, DuctSegmentNode, DuctTerminalNode, + ElectricalConduitNode, + ElectricalDeviceNode, ElevatorNode, EyebrowVentNode, FenceNode, @@ -41,6 +43,7 @@ import type { StairSegmentNode, TurbineVentNode, WallNode, + WaterLineNode, WindowNode, ZoneNode, } from '../schema' @@ -125,6 +128,9 @@ export type PipeFittingEvent = NodeEvent export type PipeTrapEvent = NodeEvent export type LinesetEvent = NodeEvent export type LiquidLineEvent = NodeEvent +export type WaterLineEvent = NodeEvent +export type ElectricalConduitEvent = NodeEvent +export type ElectricalDeviceEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -288,6 +294,9 @@ type EditorEvents = GridEvents & NodeEvents<'pipe-trap', PipeTrapEvent> & NodeEvents<'lineset', LinesetEvent> & NodeEvents<'liquid-line', LiquidLineEvent> & + NodeEvents<'water-line', WaterLineEvent> & + NodeEvents<'electrical-conduit', ElectricalConduitEvent> & + NodeEvents<'electrical-device', ElectricalDeviceEvent> & CameraControlEvents & ToolEvents & GuideEvents & diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index e9d270dcc..fdd07f5de 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -62,6 +62,10 @@ export { DownspoutNode } from './nodes/downspout' export { DuctFittingNode } from './nodes/duct-fitting' export { DuctSegmentNode } from './nodes/duct-segment' export { DuctTerminalNode } from './nodes/duct-terminal' +export { ElectricalConduitNode } from './nodes/electrical-conduit' +export type { ElectricalConduitNodeId } from './nodes/electrical-conduit' +export { ElectricalDeviceNode } from './nodes/electrical-device' +export type { ElectricalDeviceNodeId } from './nodes/electrical-device' export { ElevatorDoorPanelStyle, ElevatorDoorStyle, @@ -166,6 +170,8 @@ export { WALL_SLOT_DEFAULT, WallNode, } from './nodes/wall' +export { WaterLineNode } from './nodes/water-line' +export type { WaterLineNodeId } from './nodes/water-line' export { WindowNode, WindowType } from './nodes/window' export { ZoneNode } from './nodes/zone' export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' diff --git a/packages/core/src/schema/nodes/electrical-conduit.ts b/packages/core/src/schema/nodes/electrical-conduit.ts new file mode 100644 index 000000000..d1badff05 --- /dev/null +++ b/packages/core/src/schema/nodes/electrical-conduit.ts @@ -0,0 +1,31 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Electrical conduit run — a protective tube carrying wiring, modeled as + * a polyline. Supports EMT (thin-wall metallic), PVC (non-metallic), and + * flexible conduit. Path coordinates are level-local meters. + */ +export const ElectricalConduitNode = BaseNode.extend({ + id: objectId('electrical-conduit'), + type: nodeType('electrical-conduit'), + // Polyline path in level-local meters. Minimum two points. + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Trade size in inches. Common: ½, ¾, 1, 1¼. + diameter: z.number().min(0.5).max(4).default(0.75), + conduitMaterial: z.enum(['emt', 'pvc', 'flex']).default('emt'), + system: z.enum(['power', 'lighting', 'data']).default('power'), + circuitId: z.string().optional(), +}).describe( + dedent` + Electrical conduit run as a polyline of 3D points. + - path: list of [x, y, z] points in level-local meters (min 2) + - diameter: trade size in inches (0.5 / 0.75 / 1 / 1.25 typical) + - conduitMaterial: emt (metallic thin-wall) | pvc | flex + - system: power | lighting | data + - circuitId: optional circuit reference label + `, +) +export type ElectricalConduitNode = z.infer +export type ElectricalConduitNodeId = ElectricalConduitNode['id'] diff --git a/packages/core/src/schema/nodes/electrical-device.ts b/packages/core/src/schema/nodes/electrical-device.ts new file mode 100644 index 000000000..569376b4d --- /dev/null +++ b/packages/core/src/schema/nodes/electrical-device.ts @@ -0,0 +1,34 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Electrical device — a point-placed fixture: outlet, switch, luminaire, + * junction box, or distribution panel. Position is in level-local meters; + * rotation is yaw around Y in radians. + */ +export const ElectricalDeviceNode = BaseNode.extend({ + id: objectId('electrical-device'), + type: nodeType('electrical-device'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Yaw around Y axis, radians. + rotation: z.number().default(0), + deviceType: z + .enum(['outlet', 'switch', 'light', 'junction-box', 'panel']) + .default('outlet'), + mounting: z.enum(['wall', 'ceiling', 'floor']).default('wall'), + circuitId: z.string().optional(), + voltage: z.union([z.literal(127), z.literal(220)]).default(127), +}).describe( + dedent` + Electrical device - outlet, switch, light fixture, junction box, or distribution panel. + - position: [x, y, z] in level-local meters + - rotation: yaw angle in radians + - deviceType: outlet | switch | light | junction-box | panel + - mounting: wall | ceiling | floor + - circuitId: optional circuit reference label + - voltage: 127 (standard) | 220 (high-voltage) + `, +) +export type ElectricalDeviceNode = z.infer +export type ElectricalDeviceNodeId = ElectricalDeviceNode['id'] diff --git a/packages/core/src/schema/nodes/water-line.ts b/packages/core/src/schema/nodes/water-line.ts new file mode 100644 index 000000000..fdd6db153 --- /dev/null +++ b/packages/core/src/schema/nodes/water-line.ts @@ -0,0 +1,29 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Pressurized water supply line — cold or hot water runs as a polyline. + * Unlike DWV pipe segments, water lines run at positive pressure and need + * no slope. Path coordinates are level-local meters. + */ +export const WaterLineNode = BaseNode.extend({ + id: objectId('water-line'), + type: nodeType('water-line'), + // Polyline path in level-local meters. Minimum two points. + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Nominal pipe size in inches. Residential supply: ¾ main, ½ branches. + diameter: z.number().min(0.25).max(4).default(0.75), + pipeMaterial: z.enum(['pvc', 'cpvc', 'pex', 'copper']).default('pex'), + system: z.enum(['cold-water', 'hot-water']).default('cold-water'), +}).describe( + dedent` + Water supply pipe - pressurized water line (cold or hot) as a polyline of 3D points. + - path: list of [x, y, z] points in level-local meters (min 2) + - diameter: nominal size in inches (0.5 / 0.75 / 1 typical residential) + - pipeMaterial: pvc | cpvc | pex | copper + - system: cold-water | hot-water + `, +) +export type WaterLineNode = z.infer +export type WaterLineNodeId = WaterLineNode['id'] diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d404367b0..f52f6053f 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -11,6 +11,8 @@ import { DownspoutNode } from './nodes/downspout' import { DuctFittingNode } from './nodes/duct-fitting' import { DuctSegmentNode } from './nodes/duct-segment' import { DuctTerminalNode } from './nodes/duct-terminal' +import { ElectricalConduitNode } from './nodes/electrical-conduit' +import { ElectricalDeviceNode } from './nodes/electrical-device' import { ElevatorNode } from './nodes/elevator' import { EyebrowVentNode } from './nodes/eyebrow-vent' import { FenceNode } from './nodes/fence' @@ -38,6 +40,7 @@ import { StairNode } from './nodes/stair' import { StairSegmentNode } from './nodes/stair-segment' import { TurbineVentNode } from './nodes/turbine-vent' import { WallNode } from './nodes/wall' +import { WaterLineNode } from './nodes/water-line' import { WindowNode } from './nodes/window' import { ZoneNode } from './nodes/zone' @@ -83,6 +86,9 @@ export const AnyNode = z.discriminatedUnion('type', [ PipeSegmentNode, PipeFittingNode, PipeTrapNode, + WaterLineNode, + ElectricalConduitNode, + ElectricalDeviceNode, ]) export type AnyNode = z.infer diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index a5076dd78..aa7287371 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -44,7 +44,13 @@ export function ParametricInspector({ footer, nodeId, onClose, -}: { footer?: React.ReactNode; nodeId?: AnyNodeId; onClose?: () => void } = {}) { + canEditField, +}: { + footer?: React.ReactNode + nodeId?: AnyNodeId + onClose?: () => void + canEditField?: (fieldKey: string) => boolean +} = {}) { const selectedIdFromSelection = useViewer((s) => s.selection.selectedIds[0]) as | AnyNodeId | undefined @@ -158,14 +164,19 @@ export function ParametricInspector({ > {parametrics.groups.map((group, gi) => ( - {group.fields.map((field, fi) => ( - } - nodeId={selectedId} - onUpdate={handleUpdate} - /> - ))} + {group.fields.map((field, fi) => { + const fKey = String(field.key) + const readOnly = canEditField ? !canEditField(fKey) : false + return ( + } + nodeId={selectedId} + onUpdate={readOnly ? () => {} : handleUpdate} + readOnly={readOnly} + /> + ) + })} ))} {TrailingSection && ( @@ -286,9 +297,10 @@ interface FieldRendererProps { field: ParamField nodeId: AnyNodeId onUpdate: (patch: Partial) => void + readOnly?: boolean } -function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) { +function FieldRenderer({ field, nodeId, onUpdate, readOnly }: FieldRendererProps) { const key = String(field.key) // Subscribe only to this field's value. Zustand compares with ===, so when // another field on the same node changes (which produces a new node object @@ -309,33 +321,39 @@ function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) { }) if (!visible) return null + const roClass = readOnly ? 'pointer-events-none opacity-50' : '' + switch (field.kind) { case 'number': { const num = typeof value === 'number' ? value : 0 const step = field.step ?? 0.01 const precision = precisionForStep(step) return ( - onUpdate({ [key]: next } as Partial)} - precision={precision} - step={step} - unit={field.unit ?? ''} - value={num} - /> +
+ onUpdate({ [key]: next } as Partial)} + precision={precision} + step={step} + unit={field.unit ?? ''} + value={num} + /> +
) } case 'boolean': { const checked = value === true return ( - onUpdate({ [key]: next } as Partial)} - /> +
+ onUpdate({ [key]: next } as Partial)} + /> +
) } @@ -343,15 +361,17 @@ function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) { const str = typeof value === 'string' ? value : (field.options[0] ?? '') if (field.display === 'segmented') { return ( - onUpdate({ [key]: next } as Partial)} - options={field.options.map((opt) => ({ label: prettifyEnumValue(opt), value: opt }))} - value={str} - /> +
+ onUpdate({ [key]: next } as Partial)} + options={field.options.map((opt) => ({ label: prettifyEnumValue(opt), value: opt }))} + value={str} + /> +
) } return ( -
+
{prettifyKey(key)} = { + kind: 'electrical-conduit', + schemaVersion: 1, + schema: ElectricalConduitNode, + category: 'utility', + distributionRole: 'run', + snapProfile: 'structural', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + path: [ + [0, 0, 0], + [3, 0, 0], + ], + diameter: 0.75, + conduitMaterial: 'emt', + system: 'power', + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: electricalConduitParametrics, + + geometry: buildElectricalConduitGeometry, + geometryKey: (n) => JSON.stringify([n.path, n.diameter, n.conduitMaterial, n.system]), + + ports: (n) => { + if (n.path.length < 2) return [] + const unit = ( + a: readonly [number, number, number], + b: readonly [number, number, number], + ): [number, number, number] => { + const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]] + const len = Math.hypot(d[0], d[1], d[2]) + return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len] + } + const first = n.path[0]! + const second = n.path[1]! + const last = n.path[n.path.length - 1]! + const prev = n.path[n.path.length - 2]! + return [ + { id: 'start', position: first, direction: unit(first, second), diameter: n.diameter, system: n.system }, + { id: 'end', position: last, direction: unit(last, prev), diameter: n.diameter, system: n.system }, + ] + }, + + floorplan: buildElectricalConduitFloorplan, + + floorplanAffordances: { + 'move-path-point': createPathPointMoveAffordance('electrical-conduit'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Start run' }, + { key: 'Click again', label: 'Place segment' }, + { key: 'E', label: 'Power / lighting / data' }, + { key: '[ / ]', label: 'Conduit size down / up' }, + { key: 'Esc', label: 'Cancel start point' }, + ], + + presentation: { + label: 'Conduit', + description: 'Electrical conduit run — EMT, PVC, or flexible.', + icon: { kind: 'iconify', name: 'lucide:zap' }, + paletteSection: 'structure', + paletteOrder: 105, + }, + + mcp: { + description: + 'An electrical conduit run defined as a polyline. Conduit trade size in inches; system is power, lighting, or data. Path coordinates are level-local meters.', + }, +} diff --git a/packages/nodes/src/electrical-conduit/floorplan.ts b/packages/nodes/src/electrical-conduit/floorplan.ts new file mode 100644 index 000000000..aca4b6d56 --- /dev/null +++ b/packages/nodes/src/electrical-conduit/floorplan.ts @@ -0,0 +1,85 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { ElectricalConduitNode } from './schema' + +// Conduit system colors — distinct enough to read at plan scale. +const POWER_COLOR = '#f59e0b' +const LIGHTING_COLOR = '#a78bfa' +const DATA_COLOR = '#34d399' + +function getConduitColor(node: ElectricalConduitNode): string { + if (node.system === 'lighting') return LIGHTING_COLOR + if (node.system === 'data') return DATA_COLOR + return POWER_COLOR +} + +/** + * Floor-plan representation of an electrical conduit run. Lines are drawn + * dashed (electrical convention) and color-coded by system: power (amber), + * lighting (violet), data (green). + */ +export function buildElectricalConduitFloorplan( + node: ElectricalConduitNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (node.path.length < 2) return null + + const points: FloorplanPoint[] = [] + const indexMap: number[] = [] + for (let i = 0; i < node.path.length; i++) { + const [x, , z] = node.path[i]! + const prev = points[points.length - 1] + if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue + points.push([x, z]) + indexMap.push(i) + } + + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const baseColor = getConduitColor(node) + const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseColor + const diameterM = node.diameter * INCHES_TO_METERS + + if (points.length < 2) { + const p = points[0] ?? [node.path[0]![0], node.path[0]![2]] + return { + kind: 'circle', + cx: p[0], + cy: p[1], + r: diameterM / 2 + 0.01, + fill: 'none', + stroke, + strokeWidth: 2, + vectorEffect: 'non-scaling-stroke', + } + } + + const children: FloorplanGeometry[] = [ + { + kind: 'polyline', + points, + stroke, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + strokeDasharray: '8 4', + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: showSelectedChrome ? 0.95 : 0.85, + }, + ] + + if (view?.selected) { + for (let k = 0; k < points.length; k++) { + children.push({ + kind: 'endpoint-handle', + point: points[k]!, + state: 'idle', + affordance: 'move-path-point', + payload: { pointIndex: indexMap[k]! }, + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/electrical-conduit/geometry.ts b/packages/nodes/src/electrical-conduit/geometry.ts new file mode 100644 index 000000000..9372abd79 --- /dev/null +++ b/packages/nodes/src/electrical-conduit/geometry.ts @@ -0,0 +1,57 @@ +import { Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three' +import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry' +import type { ElectricalConduitNode } from './schema' + +const EMT_COLOR = '#9ca3af' +const PVC_COLOR = '#d1d5db' +const FLEX_COLOR = '#6b7280' + +const RADIAL_SEGMENTS = 16 + +function getConduitColor(node: ElectricalConduitNode): string { + if (node.conduitMaterial === 'emt') return EMT_COLOR + if (node.conduitMaterial === 'flex') return FLEX_COLOR + return PVC_COLOR +} + +function createConduitMaterial(node: ElectricalConduitNode): MeshStandardMaterial { + const isMetal = node.conduitMaterial === 'emt' + return new MeshStandardMaterial({ + color: getConduitColor(node), + metalness: isMetal ? 0.7 : 0.05, + roughness: isMetal ? 0.35 : 0.55, + }) +} + +/** + * Pure geometry builder for an electrical conduit run: capped cylinder + * sections between consecutive path points with sphere hubs at interior + * joints (proper conduit bodies come in a later slice). + */ +export function buildElectricalConduitGeometry(node: ElectricalConduitNode): Group { + const group = new Group() + if (node.path.length < 2) return group + + const radius = (node.diameter * INCHES_TO_METERS) / 2 + const material = createConduitMaterial(node) + const points = node.path.map(([x, y, z]) => new Vector3(x, y, z)) + + for (let i = 0; i < points.length - 1; i++) { + const a = points[i] as Vector3 + const b = points[i + 1] as Vector3 + const mesh = buildSection(a, b, radius, material, `conduit-section-${i}`) + if (mesh) group.add(mesh) + } + + for (let i = 1; i < points.length - 1; i++) { + const hub = new Mesh( + new SphereGeometry(radius * 1.1, RADIAL_SEGMENTS, 10), + material, + ) + hub.name = `conduit-hub-${i}` + hub.position.copy(points[i] as Vector3) + group.add(hub) + } + + return group +} diff --git a/packages/nodes/src/electrical-conduit/index.ts b/packages/nodes/src/electrical-conduit/index.ts new file mode 100644 index 000000000..8118aa552 --- /dev/null +++ b/packages/nodes/src/electrical-conduit/index.ts @@ -0,0 +1,3 @@ +export { electricalConduitDefinition } from './definition' +export { buildElectricalConduitGeometry } from './geometry' +export { ElectricalConduitNode } from './schema' diff --git a/packages/nodes/src/electrical-conduit/parametrics.ts b/packages/nodes/src/electrical-conduit/parametrics.ts new file mode 100644 index 000000000..399e53430 --- /dev/null +++ b/packages/nodes/src/electrical-conduit/parametrics.ts @@ -0,0 +1,36 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { ElectricalConduitNode } from './schema' + +export const electricalConduitParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Electrical', + fields: [ + { + key: 'system', + kind: 'enum', + options: ['power', 'lighting', 'data'], + display: 'segmented', + }, + { + key: 'diameter', + kind: 'number', + unit: 'in', + min: 0.5, + max: 4, + step: 0.25, + }, + ], + }, + { + label: 'Construction', + fields: [ + { + key: 'conduitMaterial', + kind: 'enum', + options: ['emt', 'pvc', 'flex'], + }, + ], + }, + ], +} diff --git a/packages/nodes/src/electrical-conduit/schema.ts b/packages/nodes/src/electrical-conduit/schema.ts new file mode 100644 index 000000000..8e2696ca5 --- /dev/null +++ b/packages/nodes/src/electrical-conduit/schema.ts @@ -0,0 +1 @@ +export { ElectricalConduitNode } from '@pascal-app/core' diff --git a/packages/nodes/src/electrical-conduit/tool.tsx b/packages/nodes/src/electrical-conduit/tool.tsx new file mode 100644 index 000000000..659498cd3 --- /dev/null +++ b/packages/nodes/src/electrical-conduit/tool.tsx @@ -0,0 +1,282 @@ +'use client' + +import { ElectricalConduitNode, emitter, type GridEvent, useScene } from '@pascal-app/core' +import { + CursorSphere, + DimensionPill, + EDITOR_LAYER, + isAngleSnapActive, + isGridSnapActive, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { Vector3 } from 'three' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { electricalConduitDefinition } from './definition' + +const PREVIEW_OPACITY = 0.55 +const SNAP_CURSOR_COLOR = '#22c55e' +const CONDUIT_SIZES_IN = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3] as const +const ANGLE_STEP_RAD = Math.PI / 4 + +// System-specific preview colors matching the floorplan colors. +const SYSTEM_COLORS = { + power: '#f59e0b', + lighting: '#a78bfa', + data: '#34d399', +} as const + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +function projectToAngleLock( + from: [number, number, number], + raw: [number, number, number], +): [number, number, number] { + const dx = raw[0] - from[0] + const dz = raw[2] - from[2] + const len = Math.hypot(dx, dz) + if (len < 1e-4) return [from[0], from[1], from[2]] + const theta = Math.atan2(dz, dx) + const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD + const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped) + const d = Math.max(0, proj) + return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d] +} + +const SYSTEMS: Array<'power' | 'lighting' | 'data'> = ['power', 'lighting', 'data'] + +/** + * Two-click placement tool for electrical conduit runs. + * E cycles power → lighting → data; [ / ] steps the conduit size. + */ +const ElectricalConduitTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const [system, setSystem] = useState<'power' | 'lighting' | 'data'>('power') + const [diameter, setDiameter] = useState( + (electricalConduitDefinition.defaults() as { diameter: number }).diameter, + ) + const [draftStart, setDraftStart] = useState<[number, number, number] | null>(null) + const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null) + const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null) + + const startRef = useRef(draftStart) + startRef.current = draftStart + const systemRef = useRef(system) + systemRef.current = system + const diameterRef = useRef(diameter) + diameterRef.current = diameter + + useEffect(() => { + if (!activeLevelId) return + + const resolve = (event: GridEvent) => { + const start = startRef.current + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const rawXZ: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]] + + if (!start) { + return { + point: [snap(rawXZ[0], step), 0, snap(rawXZ[2], step)] as [number, number, number], + snapped: null, + } + } + + const angleLocked = isAngleSnapActive() + const angled = angleLocked ? projectToAngleLock(start, rawXZ) : rawXZ + let end: [number, number, number] + if (!angleLocked) { + end = [snap(angled[0], step), angled[1], snap(angled[2], step)] + } else { + const dx = angled[0] - start[0] + const dz = angled[2] - start[2] + const len = Math.hypot(dx, dz) + if (len < 1e-6) { + end = angled + } else { + const s = snap(len, step) / len + end = [start[0] + dx * s, angled[1], start[2] + dz * s] + } + } + return { point: end, snapped: null } + } + + const commitSegment = (start: [number, number, number], end: [number, number, number]) => { + const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]) + if (length < 1e-4) return + const node = ElectricalConduitNode.parse({ + ...electricalConduitDefinition.defaults(), + path: [start, end], + diameter: diameterRef.current, + system: systemRef.current, + }) + useScene.getState().createNode(node, activeLevelId) + triggerSFX('sfx:item-place') + setDraftStart(end) + setSnapTarget(null) + } + + const onMove = (event: GridEvent) => { + const { point, snapped } = resolve(event) + setCursorPos(point) + setSnapTarget(snapped) + } + + const onClick = (event: GridEvent) => { + const { point } = resolve(event) + const start = startRef.current + if (!start) { + triggerSFX('sfx:grid-snap') + setDraftStart(point) + return + } + commitSegment(start, point) + } + + const stepDiameter = (step: 1 | -1) => { + const sizes = CONDUIT_SIZES_IN + const current = diameterRef.current + let nearest = 0 + for (let i = 1; i < sizes.length; i++) { + if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i + } + const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]! + if (next === current) return + setDiameter(next) + triggerSFX('sfx:grid-snap') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === '[') { + e.preventDefault() + stepDiameter(-1) + } else if (e.key === ']') { + e.preventDefault() + stepDiameter(1) + } else if (e.key === 'e' || e.key === 'E') { + e.preventDefault() + setSystem((s) => { + const idx = SYSTEMS.indexOf(s) + return SYSTEMS[(idx + 1) % SYSTEMS.length]! + }) + triggerSFX('sfx:grid-snap') + } + } + + const onCancel = () => { + if (!startRef.current) return + markToolCancelConsumed() + setDraftStart(null) + setCursorPos(null) + setSnapTarget(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + const pillParts = cursorPos + ? [ + ...(['x', 'y', 'z'] as const).map((axis, i) => ({ + key: axis, + prefix: axis.toUpperCase(), + value: draftStart ? cursorPos[i]! - draftStart[i]! : cursorPos[i]!, + signed: !!draftStart, + })), + { key: 'diameter', prefix: 'Ø', value: diameter * 0.0254, signed: false }, + ] + : null + + const lineColor = SYSTEM_COLORS[system] + + return ( + + {cursorPos && ( + <> + + {pillParts && ( + + +
+ +
+ {system.charAt(0).toUpperCase() + system.slice(1)} · E system +
+
+ +
+ )} + + )} + {draftStart && ( + + + + + )} + {draftStart && cursorPos && ( + + )} +
+ ) +} + +function PreviewConduit({ + a, + b, + diameterIn, + color, +}: { + a: [number, number, number] + b: [number, number, number] + diameterIn: number + color: string +}) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + const radius = (diameterIn * 0.0254) / 2 + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default ElectricalConduitTool diff --git a/packages/nodes/src/electrical-device/definition.ts b/packages/nodes/src/electrical-device/definition.ts new file mode 100644 index 000000000..42f07b594 --- /dev/null +++ b/packages/nodes/src/electrical-device/definition.ts @@ -0,0 +1,69 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildElectricalDeviceFloorplan } from './floorplan' +import { buildElectricalDeviceGeometry } from './geometry' +import { electricalDeviceParametrics } from './parametrics' +import { ElectricalDeviceNode } from './schema' + +/** + * Point-placed electrical device — outlet, switch, luminaire, junction box, + * or distribution panel. Uses a simple click-to-place tool with R/T rotation. + * Phase 1: box-mesh geometry and labeled-circle floor-plan symbol. + */ +export const electricalDeviceDefinition: NodeDefinition = { + kind: 'electrical-device', + schemaVersion: 1, + schema: ElectricalDeviceNode, + category: 'utility', + distributionRole: 'terminal', + snapProfile: 'item', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + deviceType: 'outlet', + mounting: 'wall', + voltage: 127, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'y', 'z'], gridSnap: true }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + }, + + parametrics: electricalDeviceParametrics, + + geometry: buildElectricalDeviceGeometry, + geometryKey: (n) => JSON.stringify([n.deviceType, n.mounting, n.voltage]), + + ports: () => [], + + floorplan: buildElectricalDeviceFloorplan, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place device' }, + { key: 'D', label: 'Cycle device type' }, + { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Esc', label: 'Exit' }, + ], + + presentation: { + label: 'Electrical Device', + description: 'Outlet, switch, luminaire, junction box, or distribution panel.', + icon: { kind: 'iconify', name: 'lucide:plug' }, + paletteSection: 'structure', + paletteOrder: 110, + }, + + mcp: { + description: + 'A point-placed electrical device. deviceType: outlet | switch | light | junction-box | panel. Position is level-local meters; rotation is yaw radians. voltage: 127 (standard) | 220 (high-power).', + }, +} diff --git a/packages/nodes/src/electrical-device/floorplan.ts b/packages/nodes/src/electrical-device/floorplan.ts new file mode 100644 index 000000000..ff12c9b7e --- /dev/null +++ b/packages/nodes/src/electrical-device/floorplan.ts @@ -0,0 +1,81 @@ +import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core' +import type { ElectricalDeviceNode } from './schema' + +const DEVICE_LABELS: Record = { + outlet: 'O', + switch: 'S', + light: 'L', + 'junction-box': 'JB', + panel: 'P', +} + +const DEVICE_COLORS: Record = { + outlet: '#f59e0b', + switch: '#64748b', + light: '#a78bfa', + 'junction-box': '#6b7280', + panel: '#374151', +} + +const DEVICE_RADIUS: Record = { + outlet: 0.08, + switch: 0.07, + light: 0.15, + 'junction-box': 0.09, + panel: 0.18, +} + +/** + * Floor-plan symbol for an electrical device. Drawn as a labelled circle + * at the device position, color-coded by device type. Rotates with the + * device's yaw so wall-mounted devices face the correct direction. + */ +export function buildElectricalDeviceFloorplan( + node: ElectricalDeviceNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const cx = node.position[0] + const cy = node.position[2] + const label = DEVICE_LABELS[node.deviceType]! + const baseColor = DEVICE_COLORS[node.deviceType]! + const r = DEVICE_RADIUS[node.deviceType]! + + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseColor + + const fontSize = r * 0.9 + + const children: FloorplanGeometry[] = [ + { + kind: 'circle', + cx, + cy, + r, + fill: 'none', + stroke, + strokeWidth: showSelectedChrome ? 2.5 : 1.5, + vectorEffect: 'non-scaling-stroke', + opacity: 0.9, + }, + { + kind: 'text', + x: cx, + y: cy, + text: label, + fontSize, + fill: stroke, + textAnchor: 'middle', + dominantBaseline: 'central', + fontWeight: 600, + opacity: 0.9, + }, + ] + + if (showSelectedChrome) { + children.push({ kind: 'move-handle', point: [cx, cy] }) + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/electrical-device/geometry.ts b/packages/nodes/src/electrical-device/geometry.ts new file mode 100644 index 000000000..e3b32dead --- /dev/null +++ b/packages/nodes/src/electrical-device/geometry.ts @@ -0,0 +1,60 @@ +import { BoxGeometry, Group, Mesh, MeshStandardMaterial } from 'three' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { ElectricalDeviceNode } from './schema' + +const DEVICE_COLORS: Record = { + outlet: '#fbbf24', + switch: '#94a3b8', + light: '#fef08a', + 'junction-box': '#6b7280', + panel: '#374151', +} + +const DEVICE_SIZE_M: Record< + ElectricalDeviceNode['deviceType'], + [number, number, number] +> = { + outlet: [0.07, 0.11, 0.04], + switch: [0.07, 0.11, 0.03], + light: [0.3, 0.06, 0.3], + 'junction-box': [0.1, 0.1, 0.1], + panel: [0.4, 0.6, 0.1], +} + +/** + * Simple box-mesh geometry for electrical devices. The box represents the + * face plate (outlet/switch), fixture (light), or enclosure (junction-box, + * panel). Actual detailed geometry comes in a later phase. + */ +export function buildElectricalDeviceGeometry(node: ElectricalDeviceNode): Group { + const group = new Group() + const [w, h, d] = DEVICE_SIZE_M[node.deviceType]! + const color = DEVICE_COLORS[node.deviceType]! + + const isMetal = node.deviceType === 'junction-box' || node.deviceType === 'panel' + const material = new MeshStandardMaterial({ + color, + metalness: isMetal ? 0.5 : 0.1, + roughness: isMetal ? 0.4 : 0.6, + }) + + const mesh = new Mesh(new BoxGeometry(w, h, d), material) + mesh.name = `electrical-device-body` + + // Mount offset: wall-mounted devices sit flush against an imaginary wall + // surface; ceiling-mounted hang from above; floor-mounted sit on grade. + const halfD = d / 2 + if (node.mounting === 'wall') { + mesh.position.set(0, node.deviceType === 'panel' ? 0 : 1.2, halfD) + } else if (node.mounting === 'ceiling') { + mesh.position.set(0, -h / 2, 0) + mesh.rotation.x = Math.PI / 2 + } else { + mesh.position.set(0, h / 2, 0) + } + + group.add(mesh) + return group +} + +export { INCHES_TO_METERS } diff --git a/packages/nodes/src/electrical-device/index.ts b/packages/nodes/src/electrical-device/index.ts new file mode 100644 index 000000000..cd412b1e9 --- /dev/null +++ b/packages/nodes/src/electrical-device/index.ts @@ -0,0 +1,3 @@ +export { electricalDeviceDefinition } from './definition' +export { buildElectricalDeviceGeometry } from './geometry' +export { ElectricalDeviceNode } from './schema' diff --git a/packages/nodes/src/electrical-device/parametrics.ts b/packages/nodes/src/electrical-device/parametrics.ts new file mode 100644 index 000000000..c37097c2d --- /dev/null +++ b/packages/nodes/src/electrical-device/parametrics.ts @@ -0,0 +1,23 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { ElectricalDeviceNode } from './schema' + +export const electricalDeviceParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Device', + fields: [ + { + key: 'deviceType', + kind: 'enum', + options: ['outlet', 'switch', 'light', 'junction-box', 'panel'], + display: 'segmented', + }, + { + key: 'mounting', + kind: 'enum', + options: ['wall', 'ceiling', 'floor'], + }, + ], + }, + ], +} diff --git a/packages/nodes/src/electrical-device/schema.ts b/packages/nodes/src/electrical-device/schema.ts new file mode 100644 index 000000000..f81dd2bf9 --- /dev/null +++ b/packages/nodes/src/electrical-device/schema.ts @@ -0,0 +1,2 @@ +export { ElectricalDeviceNode } from '@pascal-app/core' +export type { ElectricalDeviceNodeId } from '@pascal-app/core' diff --git a/packages/nodes/src/electrical-device/tool.tsx b/packages/nodes/src/electrical-device/tool.tsx new file mode 100644 index 000000000..8d80cc121 --- /dev/null +++ b/packages/nodes/src/electrical-device/tool.tsx @@ -0,0 +1,156 @@ +'use client' + +import { ElectricalDeviceNode, emitter, type GridEvent, useScene } from '@pascal-app/core' +import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo, useRef, useState } from 'react' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { electricalDeviceDefinition } from './definition' +import { buildElectricalDeviceGeometry } from './geometry' + +const PREVIEW_OPACITY = 0.55 +const ROTATE_STEP_RAD = Math.PI / 4 + +const DEVICE_TYPES: Array = [ + 'outlet', + 'switch', + 'light', + 'junction-box', + 'panel', +] + +const DEVICE_LABELS: Record = { + outlet: 'Outlet', + switch: 'Switch', + light: 'Light', + 'junction-box': 'Junction Box', + panel: 'Panel', +} + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** + * Click-place tool for electrical devices. **D** cycles the device type; + * **R / T** rotate the device ±45°. Grid snap follows the active snapping mode. + */ +const ElectricalDeviceTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const [cursor, setCursor] = useState<[number, number, number] | null>(null) + const [yaw, setYaw] = useState(0) + const [deviceType, setDeviceType] = useState('outlet') + + const yawRef = useRef(0) + const deviceTypeRef = useRef(deviceType) + deviceTypeRef.current = deviceType + + const previewNode = useMemo( + () => + ElectricalDeviceNode.parse({ + ...electricalDeviceDefinition.defaults(), + deviceType, + }), + [deviceType], + ) + + const ghost = useMemo(() => { + const group = buildElectricalDeviceGeometry(previewNode) + group.traverse((child) => { + const mesh = child as { material?: { transparent: boolean; opacity: number } } + if (mesh.material) { + mesh.material.transparent = true + mesh.material.opacity = PREVIEW_OPACITY + } + }) + return group + }, [previewNode]) + + useEffect(() => { + if (!activeLevelId) return + + const resolve = (event: GridEvent) => { + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + return [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] as [ + number, + number, + number, + ] + } + + const onMove = (event: GridEvent) => { + setCursor(resolve(event)) + } + + const onClick = (event: GridEvent) => { + const position = resolve(event) + const device = ElectricalDeviceNode.parse({ + ...electricalDeviceDefinition.defaults(), + deviceType: deviceTypeRef.current, + position, + rotation: yawRef.current, + }) + useScene.getState().createNode(device, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [device.id] }) + triggerSFX('sfx:item-place') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + const key = e.key + if (key === 'r' || key === 'R' || key === 't' || key === 'T') { + e.preventDefault() + e.stopPropagation() + const steps = key === 't' || key === 'T' ? -1 : 1 + yawRef.current += steps * ROTATE_STEP_RAD + setYaw(yawRef.current) + triggerSFX('sfx:item-rotate') + } else if (key === 'd' || key === 'D') { + e.preventDefault() + setDeviceType((current) => { + const idx = DEVICE_TYPES.indexOf(current) + return DEVICE_TYPES[(idx + 1) % DEVICE_TYPES.length]! + }) + triggerSFX('sfx:grid-snap') + } + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + window.removeEventListener('keydown', onKeyDown, true) + } + }, [activeLevelId]) + + if (!activeLevelId || !cursor) return null + + return ( + + + + + +
+ {DEVICE_LABELS[deviceType]} + + · + + D device · R/T rotate +
+ +
+ ) +} + +export default ElectricalDeviceTool diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index ee5a13b32..f19f8bdce 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -21,9 +21,12 @@ import { itemDefinition } from './item' import { levelDefinition } from './level' import { linesetDefinition } from './lineset' import { liquidLineDefinition } from './liquid-line' +import { electricalConduitDefinition } from './electrical-conduit' +import { electricalDeviceDefinition } from './electrical-device' import { pipeFittingDefinition } from './pipe-fitting' import { pipeSegmentDefinition } from './pipe-segment' import { pipeTrapDefinition } from './pipe-trap' +import { waterLineDefinition } from './water-line' import { ridgeVentDefinition } from './ridge-vent' import { roofDefinition } from './roof' import { roofSegmentDefinition } from './roof-segment' @@ -108,6 +111,10 @@ export const builtinPlugin: Plugin = { pipeSegmentDefinition as unknown as AnyNodeDefinition, pipeFittingDefinition as unknown as AnyNodeDefinition, pipeTrapDefinition as unknown as AnyNodeDefinition, + // MEP — pressurized water supply and electrical systems. + waterLineDefinition as unknown as AnyNodeDefinition, + electricalConduitDefinition as unknown as AnyNodeDefinition, + electricalDeviceDefinition as unknown as AnyNodeDefinition, ], } @@ -133,9 +140,12 @@ export { itemDefinition } from './item' export { levelDefinition } from './level' export { linesetDefinition } from './lineset' export { liquidLineDefinition, useLiquidLineToolOptions } from './liquid-line' +export { electricalConduitDefinition } from './electrical-conduit' +export { electricalDeviceDefinition } from './electrical-device' export { pipeFittingDefinition } from './pipe-fitting' export { pipeSegmentDefinition } from './pipe-segment' export { pipeTrapDefinition } from './pipe-trap' +export { waterLineDefinition } from './water-line' export { ridgeVentDefinition } from './ridge-vent' export { roofDefinition } from './roof' export { roofSegmentDefinition } from './roof-segment' diff --git a/packages/nodes/src/mep/catalog.ts b/packages/nodes/src/mep/catalog.ts new file mode 100644 index 000000000..3c4883905 --- /dev/null +++ b/packages/nodes/src/mep/catalog.ts @@ -0,0 +1,133 @@ +/** + * Typed MEP families catalog — reference data for the UI picker and MCP + * tool-call suggestions. Each entry maps to a node kind + defaults that + * produce a sensible first placement without further configuration. + */ + +export type MepFamily = { + id: string + label: string + kind: string + defaults: Record + section: 'hvac' | 'plumbing' | 'electrical' +} + +export const MEP_CATALOG: MepFamily[] = [ + // ── HVAC ────────────────────────────────────────────────────────────── + { + id: 'round-duct', + label: 'Round Duct', + kind: 'duct-segment', + defaults: { diameter: 0.3, ductMaterial: 'galvanized' }, + section: 'hvac', + }, + { + id: 'supply-register', + label: 'Supply Register', + kind: 'duct-terminal', + defaults: { terminalType: 'supply' }, + section: 'hvac', + }, + { + id: 'hvac-unit', + label: 'HVAC Unit', + kind: 'hvac-equipment', + defaults: {}, + section: 'hvac', + }, + + // ── Plumbing ────────────────────────────────────────────────────────── + { + id: 'cold-water-line', + label: 'Cold Water Line', + kind: 'water-line', + defaults: { system: 'cold-water', diameter: 0.75, pipeMaterial: 'pex' }, + section: 'plumbing', + }, + { + id: 'hot-water-line', + label: 'Hot Water Line', + kind: 'water-line', + defaults: { system: 'hot-water', diameter: 0.75, pipeMaterial: 'pex' }, + section: 'plumbing', + }, + { + id: 'dwv-pipe', + label: 'DWV Pipe', + kind: 'pipe-segment', + defaults: { diameter: 3, pipeMaterial: 'pvc', system: 'waste' }, + section: 'plumbing', + }, + { + id: 'trap', + label: 'P-Trap', + kind: 'pipe-trap', + defaults: { diameter: 2, pipeMaterial: 'pvc' }, + section: 'plumbing', + }, + + // ── Electrical ──────────────────────────────────────────────────────── + { + id: 'outlet-127v', + label: 'Outlet 127V', + kind: 'electrical-device', + defaults: { deviceType: 'outlet', voltage: 127, mounting: 'wall' }, + section: 'electrical', + }, + { + id: 'outlet-220v', + label: 'Outlet 220V', + kind: 'electrical-device', + defaults: { deviceType: 'outlet', voltage: 220, mounting: 'wall' }, + section: 'electrical', + }, + { + id: 'switch-simple', + label: 'Switch', + kind: 'electrical-device', + defaults: { deviceType: 'switch', voltage: 127, mounting: 'wall' }, + section: 'electrical', + }, + { + id: 'ceiling-light', + label: 'Ceiling Light', + kind: 'electrical-device', + defaults: { deviceType: 'light', voltage: 127, mounting: 'ceiling' }, + section: 'electrical', + }, + { + id: 'junction-box', + label: 'Junction Box', + kind: 'electrical-device', + defaults: { deviceType: 'junction-box', voltage: 127, mounting: 'wall' }, + section: 'electrical', + }, + { + id: 'distribution-panel', + label: 'Distribution Panel', + kind: 'electrical-device', + defaults: { deviceType: 'panel', voltage: 127, mounting: 'wall' }, + section: 'electrical', + }, + { + id: 'power-conduit', + label: 'Power Conduit', + kind: 'electrical-conduit', + defaults: { system: 'power', diameter: 0.75, conduitMaterial: 'emt' }, + section: 'electrical', + }, + { + id: 'lighting-conduit', + label: 'Lighting Conduit', + kind: 'electrical-conduit', + defaults: { system: 'lighting', diameter: 0.5, conduitMaterial: 'emt' }, + section: 'electrical', + }, + { + id: 'data-conduit', + label: 'Data Conduit', + kind: 'electrical-conduit', + defaults: { system: 'data', diameter: 0.5, conduitMaterial: 'pvc' }, + section: 'electrical', + }, +] diff --git a/packages/nodes/src/water-line/definition.ts b/packages/nodes/src/water-line/definition.ts new file mode 100644 index 000000000..30605c645 --- /dev/null +++ b/packages/nodes/src/water-line/definition.ts @@ -0,0 +1,93 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { createPathPointMoveAffordance } from '../shared/path-point-affordance' +import { buildWaterLineFloorplan } from './floorplan' +import { buildWaterLineGeometry } from './geometry' +import { waterLineParametrics } from './parametrics' +import { WaterLineNode } from './schema' + +/** + * Pressurized water supply line — cold or hot. The plumbing sibling of + * `pipe-segment` but without slope (pressurized supply runs horizontally + * or vertically at any angle). Phase 1: run geometry and floor-plan only. + */ +export const waterLineDefinition: NodeDefinition = { + kind: 'water-line', + schemaVersion: 1, + schema: WaterLineNode, + category: 'utility', + distributionRole: 'run', + snapProfile: 'structural', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + path: [ + [0, 0, 0], + [3, 0, 0], + ], + diameter: 0.75, + pipeMaterial: 'pex', + system: 'cold-water', + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: waterLineParametrics, + + geometry: buildWaterLineGeometry, + geometryKey: (n) => JSON.stringify([n.path, n.diameter, n.pipeMaterial, n.system]), + + ports: (n) => { + if (n.path.length < 2) return [] + const unit = ( + a: readonly [number, number, number], + b: readonly [number, number, number], + ): [number, number, number] => { + const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]] + const len = Math.hypot(d[0], d[1], d[2]) + return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len] + } + const first = n.path[0]! + const second = n.path[1]! + const last = n.path[n.path.length - 1]! + const prev = n.path[n.path.length - 2]! + return [ + { id: 'start', position: first, direction: unit(first, second), diameter: n.diameter, system: n.system }, + { id: 'end', position: last, direction: unit(last, prev), diameter: n.diameter, system: n.system }, + ] + }, + + floorplan: buildWaterLineFloorplan, + + floorplanAffordances: { + 'move-path-point': createPathPointMoveAffordance('water-line'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Start run' }, + { key: 'Click again', label: 'Place segment' }, + { key: 'H', label: 'Cold / hot water' }, + { key: '[ / ]', label: 'Pipe size down / up' }, + { key: 'Esc', label: 'Cancel start point' }, + ], + + presentation: { + label: 'Water Line', + description: 'Pressurized water supply line — cold or hot water run.', + icon: { kind: 'iconify', name: 'lucide:droplets' }, + paletteSection: 'structure', + paletteOrder: 100, + }, + + mcp: { + description: + 'A pressurized water supply run defined as a polyline. Path coordinates are level-local meters. System is cold-water or hot-water; diameter in nominal inches.', + }, +} diff --git a/packages/nodes/src/water-line/floorplan.ts b/packages/nodes/src/water-line/floorplan.ts new file mode 100644 index 000000000..880c91286 --- /dev/null +++ b/packages/nodes/src/water-line/floorplan.ts @@ -0,0 +1,77 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { WaterLineNode } from './schema' + +const COLD_COLOR = '#3b82f6' +const HOT_COLOR = '#ef4444' + +/** + * Floor-plan representation of a water supply run. Cold lines draw solid + * blue; hot lines draw solid red. Both at the pipe's nominal width for + * readability at residential scale. + */ +export function buildWaterLineFloorplan( + node: WaterLineNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (node.path.length < 2) return null + + const points: FloorplanPoint[] = [] + const indexMap: number[] = [] + for (let i = 0; i < node.path.length; i++) { + const [x, , z] = node.path[i]! + const prev = points[points.length - 1] + if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue + points.push([x, z]) + indexMap.push(i) + } + + const diameterM = node.diameter * INCHES_TO_METERS + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const baseColor = node.system === 'hot-water' ? HOT_COLOR : COLD_COLOR + const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseColor + + // Vertical stack — collapse to a circle. + if (points.length < 2) { + const p = points[0] ?? [node.path[0]![0], node.path[0]![2]] + return { + kind: 'circle', + cx: p[0], + cy: p[1], + r: diameterM / 2 + 0.01, + fill: 'none', + stroke, + strokeWidth: 2, + vectorEffect: 'non-scaling-stroke', + opacity: 0.9, + } + } + + const children: FloorplanGeometry[] = [ + { + kind: 'polyline', + points, + stroke, + strokeWidth: diameterM, + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: showSelectedChrome ? 0.95 : 0.8, + }, + ] + + if (view?.selected) { + for (let k = 0; k < points.length; k++) { + children.push({ + kind: 'endpoint-handle', + point: points[k]!, + state: 'idle', + affordance: 'move-path-point', + payload: { pointIndex: indexMap[k]! }, + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/water-line/geometry.ts b/packages/nodes/src/water-line/geometry.ts new file mode 100644 index 000000000..2cbc48af6 --- /dev/null +++ b/packages/nodes/src/water-line/geometry.ts @@ -0,0 +1,66 @@ +import { Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three' +import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry' +import type { WaterLineNode } from './schema' + +// System-specific colors. Cold water reads blue; hot water reads warm red. +const COLD_WATER_COLOR = '#93c5fd' +const HOT_WATER_COLOR = '#fca5a5' + +const COPPER_COLOR = '#b87333' +const PEX_COLOR_COLD = '#60a5fa' +const PEX_COLOR_HOT = '#f87171' +const PVC_COLOR = '#e5e7eb' +const CPVC_COLOR = '#fde68a' + +const RADIAL_SEGMENTS = 18 + +function getWaterLineColor(node: WaterLineNode): string { + if (node.pipeMaterial === 'copper') return COPPER_COLOR + if (node.pipeMaterial === 'cpvc') return CPVC_COLOR + if (node.pipeMaterial === 'pex') { + return node.system === 'hot-water' ? PEX_COLOR_HOT : PEX_COLOR_COLD + } + // pvc + return node.system === 'hot-water' ? HOT_WATER_COLOR : COLD_WATER_COLOR +} + +function createWaterLineMaterial(node: WaterLineNode): MeshStandardMaterial { + const isMetal = node.pipeMaterial === 'copper' + return new MeshStandardMaterial({ + color: getWaterLineColor(node), + metalness: isMetal ? 0.6 : 0.05, + roughness: isMetal ? 0.4 : 0.5, + }) +} + +/** + * Pure geometry builder for a pressurized water supply run: capped cylinder + * sections between consecutive path points with sphere hubs at interior joints. + */ +export function buildWaterLineGeometry(node: WaterLineNode): Group { + const group = new Group() + if (node.path.length < 2) return group + + const radius = (node.diameter * INCHES_TO_METERS) / 2 + const material = createWaterLineMaterial(node) + const points = node.path.map(([x, y, z]) => new Vector3(x, y, z)) + + for (let i = 0; i < points.length - 1; i++) { + const a = points[i] as Vector3 + const b = points[i + 1] as Vector3 + const mesh = buildSection(a, b, radius, material, `water-section-${i}`) + if (mesh) group.add(mesh) + } + + for (let i = 1; i < points.length - 1; i++) { + const hub = new Mesh( + new SphereGeometry(radius * 1.1, RADIAL_SEGMENTS, 10), + material, + ) + hub.name = `water-hub-${i}` + hub.position.copy(points[i] as Vector3) + group.add(hub) + } + + return group +} diff --git a/packages/nodes/src/water-line/index.ts b/packages/nodes/src/water-line/index.ts new file mode 100644 index 000000000..32ff87bd9 --- /dev/null +++ b/packages/nodes/src/water-line/index.ts @@ -0,0 +1,3 @@ +export { waterLineDefinition } from './definition' +export { buildWaterLineGeometry } from './geometry' +export { WaterLineNode } from './schema' diff --git a/packages/nodes/src/water-line/parametrics.ts b/packages/nodes/src/water-line/parametrics.ts new file mode 100644 index 000000000..db2df1306 --- /dev/null +++ b/packages/nodes/src/water-line/parametrics.ts @@ -0,0 +1,36 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { WaterLineNode } from './schema' + +export const waterLineParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Supply', + fields: [ + { + key: 'system', + kind: 'enum', + options: ['cold-water', 'hot-water'], + display: 'segmented', + }, + { + key: 'diameter', + kind: 'number', + unit: 'in', + min: 0.25, + max: 4, + step: 0.25, + }, + ], + }, + { + label: 'Construction', + fields: [ + { + key: 'pipeMaterial', + kind: 'enum', + options: ['pex', 'copper', 'cpvc', 'pvc'], + }, + ], + }, + ], +} diff --git a/packages/nodes/src/water-line/schema.ts b/packages/nodes/src/water-line/schema.ts new file mode 100644 index 000000000..8eee9aabe --- /dev/null +++ b/packages/nodes/src/water-line/schema.ts @@ -0,0 +1 @@ +export { WaterLineNode } from '@pascal-app/core' diff --git a/packages/nodes/src/water-line/tool.tsx b/packages/nodes/src/water-line/tool.tsx new file mode 100644 index 000000000..6749fa787 --- /dev/null +++ b/packages/nodes/src/water-line/tool.tsx @@ -0,0 +1,270 @@ +'use client' + +import { emitter, type GridEvent, WaterLineNode, useScene } from '@pascal-app/core' +import { + CursorSphere, + DimensionPill, + EDITOR_LAYER, + isAngleSnapActive, + isGridSnapActive, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { Vector3 } from 'three' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { waterLineDefinition } from './definition' + +const PREVIEW_OPACITY = 0.55 +const SNAP_CURSOR_COLOR = '#22c55e' +const WATER_DIAMETERS_IN = [0.25, 0.375, 0.5, 0.75, 1, 1.25, 1.5, 2] as const +const ANGLE_STEP_RAD = Math.PI / 4 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +function projectToAngleLock( + from: [number, number, number], + raw: [number, number, number], +): [number, number, number] { + const dx = raw[0] - from[0] + const dz = raw[2] - from[2] + const len = Math.hypot(dx, dz) + if (len < 1e-4) return [from[0], from[1], from[2]] + const theta = Math.atan2(dz, dx) + const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD + const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped) + const d = Math.max(0, proj) + return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d] +} + +/** + * Two-click placement tool for pressurized water supply runs. + * H toggles cold ↔ hot; [ / ] steps the pipe size. + */ +const WaterLineTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const [system, setSystem] = useState<'cold-water' | 'hot-water'>('cold-water') + const [diameter, setDiameter] = useState( + (waterLineDefinition.defaults() as { diameter: number }).diameter, + ) + const [draftStart, setDraftStart] = useState<[number, number, number] | null>(null) + const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null) + const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null) + + const startRef = useRef(draftStart) + startRef.current = draftStart + const systemRef = useRef(system) + systemRef.current = system + const diameterRef = useRef(diameter) + diameterRef.current = diameter + + useEffect(() => { + if (!activeLevelId) return + + const resolve = (event: GridEvent) => { + const start = startRef.current + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const rawXZ: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]] + + if (!start) { + return { + point: [snap(rawXZ[0], step), 0, snap(rawXZ[2], step)] as [number, number, number], + snapped: null, + } + } + + const angleLocked = isAngleSnapActive() + const angled = angleLocked ? projectToAngleLock(start, rawXZ) : rawXZ + let end: [number, number, number] + if (!angleLocked) { + end = [snap(angled[0], step), angled[1], snap(angled[2], step)] + } else { + const dx = angled[0] - start[0] + const dz = angled[2] - start[2] + const len = Math.hypot(dx, dz) + if (len < 1e-6) { + end = angled + } else { + const s = snap(len, step) / len + end = [start[0] + dx * s, angled[1], start[2] + dz * s] + } + } + return { point: end, snapped: null } + } + + const commitSegment = (start: [number, number, number], end: [number, number, number]) => { + const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]) + if (length < 1e-4) return + const node = WaterLineNode.parse({ + ...waterLineDefinition.defaults(), + path: [start, end], + diameter: diameterRef.current, + system: systemRef.current, + }) + useScene.getState().createNode(node, activeLevelId) + triggerSFX('sfx:item-place') + setDraftStart(end) + setSnapTarget(null) + } + + const onMove = (event: GridEvent) => { + const { point, snapped } = resolve(event) + setCursorPos(point) + setSnapTarget(snapped) + } + + const onClick = (event: GridEvent) => { + const { point } = resolve(event) + const start = startRef.current + if (!start) { + triggerSFX('sfx:grid-snap') + setDraftStart(point) + return + } + commitSegment(start, point) + } + + const stepDiameter = (step: 1 | -1) => { + const sizes = WATER_DIAMETERS_IN + const current = diameterRef.current + let nearest = 0 + for (let i = 1; i < sizes.length; i++) { + if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i + } + const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]! + if (next === current) return + setDiameter(next) + triggerSFX('sfx:grid-snap') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === '[') { + e.preventDefault() + stepDiameter(-1) + } else if (e.key === ']') { + e.preventDefault() + stepDiameter(1) + } else if (e.key === 'h' || e.key === 'H') { + e.preventDefault() + setSystem((s) => (s === 'cold-water' ? 'hot-water' : 'cold-water')) + triggerSFX('sfx:grid-snap') + } + } + + const onCancel = () => { + if (!startRef.current) return + markToolCancelConsumed() + setDraftStart(null) + setCursorPos(null) + setSnapTarget(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + const pillParts = cursorPos + ? [ + ...(['x', 'y', 'z'] as const).map((axis, i) => ({ + key: axis, + prefix: axis.toUpperCase(), + value: draftStart ? cursorPos[i]! - draftStart[i]! : cursorPos[i]!, + signed: !!draftStart, + })), + { key: 'diameter', prefix: 'Ø', value: diameter * 0.0254, signed: false }, + ] + : null + + const lineColor = system === 'hot-water' ? '#f87171' : '#60a5fa' + + return ( + + {cursorPos && ( + <> + + {pillParts && ( + + +
+ +
+ {system === 'hot-water' ? 'Hot Water' : 'Cold Water'} · H system +
+
+ +
+ )} + + )} + {draftStart && ( + + + + + )} + {draftStart && cursorPos && ( + + )} +
+ ) +} + +function PreviewLine({ + a, + b, + diameterIn, + color, +}: { + a: [number, number, number] + b: [number, number, number] + diameterIn: number + color: string +}) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + const radius = (diameterIn * 0.0254) / 2 + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default WaterLineTool