319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
generateSceneMaterialId,
|
|
type ItemNode,
|
|
type MaterialSchema,
|
|
nodeRegistry,
|
|
pointInPolygon2D,
|
|
pointOnSegment,
|
|
type SceneMaterial,
|
|
type SceneMaterialId,
|
|
type SlabNode,
|
|
type Space,
|
|
slotLabelFromId,
|
|
toSceneMaterialRef,
|
|
useScene,
|
|
type WallNode,
|
|
} from '@pascal-app/core'
|
|
|
|
/**
|
|
* Painter application scope — how far one paint click spreads. The scope set is
|
|
* DERIVED from the hovered node, not a per-kind table: any slot-model node with
|
|
* more than one slot offers `object` (whole node); a node with an `asset` offers
|
|
* `matching` (every instance of that asset); a kind that declares
|
|
* `capabilities.paint.roomScope` offers `room`. One global mode (not per-tool),
|
|
* defaulting to the narrowest `'single'`; the active interaction's HUD shows +
|
|
* cycles it within the hovered node's set.
|
|
*/
|
|
export type PaintScope = 'single' | 'object' | 'matching' | 'room'
|
|
|
|
/** What the paint HUD needs to render + cycle the scope chip for a hover. */
|
|
export type PaintHoverInfo = {
|
|
/** The scopes available for the hovered node, in cycle order (always ≥ 1). */
|
|
scopes: PaintScope[]
|
|
/** Display name of the hovered slot — the label for the `'single'` scope. */
|
|
slotLabel: string
|
|
/** Kind noun for the `'object'` label (e.g. "Whole shelf"). */
|
|
nodeNoun: string
|
|
}
|
|
|
|
function nodeHasAsset(node: AnyNode): boolean {
|
|
return Boolean((node as { asset?: { id?: string } }).asset?.id)
|
|
}
|
|
|
|
function nodeOffersRoomScope(node: AnyNode): boolean {
|
|
return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true
|
|
}
|
|
|
|
/**
|
|
* The scopes a hovered node offers, derived from the node itself: every node
|
|
* paints `single`; > 1 slot adds `object`; an `asset` adds `matching`; a
|
|
* `roomScope`-declaring kind adds `room`. `slotRoles` is the node's full slot set
|
|
* (declared or mesh-derived), passed in by the caller.
|
|
*/
|
|
export function availablePaintScopes(args: { node: AnyNode; slotRoles: string[] }): PaintScope[] {
|
|
const scopes: PaintScope[] = ['single']
|
|
if (args.slotRoles.length > 1) scopes.push('object')
|
|
if (nodeHasAsset(args.node)) scopes.push('matching')
|
|
if (nodeOffersRoomScope(args.node)) scopes.push('room')
|
|
return scopes
|
|
}
|
|
|
|
export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintScope {
|
|
const list = scopes.length > 0 ? scopes : (['single'] as PaintScope[])
|
|
const index = list.indexOf(scope)
|
|
return list[(index + 1) % list.length] ?? 'single'
|
|
}
|
|
|
|
export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string {
|
|
switch (scope) {
|
|
case 'object':
|
|
return `Whole ${info.nodeNoun}`
|
|
case 'matching':
|
|
return 'All matching'
|
|
case 'room':
|
|
return 'Room'
|
|
default:
|
|
return info.slotLabel || 'This surface'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* All paintable slot roles of a node. Prefers the kind's declared
|
|
* `capabilities.slots` (node-authored, stable); falls back to the runtime mesh
|
|
* tags via the injected `meshSlotRoles` for kinds whose slots come from a GLB
|
|
* (items) rather than a declaration.
|
|
*/
|
|
export function nodeSlotRoles(node: AnyNode, meshSlotRoles: (node: AnyNode) => string[]): string[] {
|
|
const declared = nodeRegistry.get(node.type)?.capabilities?.slots?.(node)
|
|
if (declared && declared.length > 0) return declared.map((slot) => slot.slotId)
|
|
return meshSlotRoles(node)
|
|
}
|
|
|
|
/** Display label for the hovered slot — declared label wins, else derived from the id. */
|
|
export function slotDisplayLabel(node: AnyNode, role: string): string {
|
|
const declared = nodeRegistry
|
|
.get(node.type)
|
|
?.capabilities?.slots?.(node)
|
|
?.find((slot) => slot.slotId === role)
|
|
return declared?.label ?? slotLabelFromId(role)
|
|
}
|
|
|
|
// ── Fan-out resolution ──────────────────────────────────────────────────────
|
|
|
|
type SlotsNode = AnyNode & { slots?: Record<string, string> }
|
|
|
|
// Room polygons are built from wall *centerline* endpoints (see
|
|
// `extractRoomPolygons`), so a wall's `start`/`end` are exact polygon vertices —
|
|
// a small tolerance only absorbs float round-trips. `Space.wallIds` is always
|
|
// empty, so room membership is resolved geometrically here instead.
|
|
const WALL_ON_BOUNDARY_TOLERANCE = 0.05
|
|
|
|
function pointOnPolygonBoundary(
|
|
point: readonly [number, number],
|
|
polygon: ReadonlyArray<readonly [number, number]>,
|
|
tolerance: number,
|
|
): boolean {
|
|
for (let i = 0; i < polygon.length; i += 1) {
|
|
const a = polygon[i]
|
|
const b = polygon[(i + 1) % polygon.length]
|
|
if (
|
|
a &&
|
|
b &&
|
|
pointOnSegment(
|
|
point as [number, number],
|
|
a as [number, number],
|
|
b as [number, number],
|
|
tolerance,
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// A wall bounds a room when both its endpoints lie on the room polygon's
|
|
// boundary (a shared wall lies on two rooms' boundaries; a wall radiating out of
|
|
// a corner has only one endpoint on it and is correctly excluded).
|
|
function wallBoundsRoom(
|
|
wall: WallNode,
|
|
polygon: ReadonlyArray<readonly [number, number]>,
|
|
): boolean {
|
|
return (
|
|
pointOnPolygonBoundary(wall.start, polygon, WALL_ON_BOUNDARY_TOLERANCE) &&
|
|
pointOnPolygonBoundary(wall.end, polygon, WALL_ON_BOUNDARY_TOLERANCE)
|
|
)
|
|
}
|
|
|
|
function polygonCentroid(
|
|
points: ReadonlyArray<readonly [number, number]>,
|
|
): [number, number] | null {
|
|
if (points.length === 0) return null
|
|
let x = 0
|
|
let z = 0
|
|
for (const point of points) {
|
|
x += point[0]
|
|
z += point[1]
|
|
}
|
|
return [x / points.length, z / points.length]
|
|
}
|
|
|
|
/**
|
|
* Expand one paint hit (`node` + resolved `role`) into the full list of
|
|
* (node, role) targets the current `scope` should paint. Returns just the
|
|
* clicked surface for `'single'`, for any target whose scope set doesn't
|
|
* include the current scope, and whenever the spread resolves to a single
|
|
* element — so callers can keep the kind-specific single-node commit for that
|
|
* case and only batch when there's genuinely more than one target.
|
|
*
|
|
* `slotRolesOf` enumerates the node's full slot set (declared or mesh-derived,
|
|
* injected by the caller) for the whole-object scope.
|
|
*/
|
|
export function resolvePaintScopeTargets(args: {
|
|
node: AnyNode
|
|
role: string
|
|
scope: PaintScope
|
|
nodes: Record<string, AnyNode>
|
|
spaces: Record<string, Space>
|
|
slotRolesOf: (node: AnyNode) => string[]
|
|
}): Array<{ nodeId: AnyNodeId; role: string }> {
|
|
const { node, role, scope, nodes, spaces, slotRolesOf } = args
|
|
const single = [{ nodeId: node.id as AnyNodeId, role }]
|
|
if (scope === 'single') return single
|
|
|
|
// Whole object: paint every slot of the clicked node. Generic across any
|
|
// slot-model kind (item, shelf, door, …) — not item-specific.
|
|
if (scope === 'object') {
|
|
const roles = slotRolesOf(node)
|
|
const set = roles.length > 0 ? roles : [role]
|
|
return set.map((slotRole) => ({ nodeId: node.id as AnyNodeId, role: slotRole }))
|
|
}
|
|
|
|
// All matching: same slot across every instance of the node's asset (items).
|
|
if (scope === 'matching') {
|
|
const assetId = (node as ItemNode).asset?.id
|
|
if (!assetId) return single
|
|
return Object.values(nodes)
|
|
.filter((other) => other.type === 'item' && (other as ItemNode).asset?.id === assetId)
|
|
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
|
|
}
|
|
|
|
if (node.type === 'wall' && scope === 'room') {
|
|
const wall = node as WallNode
|
|
const space = Object.values(spaces).find((candidate) => wallBoundsRoom(wall, candidate.polygon))
|
|
if (!space) return single
|
|
return Object.values(nodes)
|
|
.filter((other) => other.type === 'wall' && wallBoundsRoom(other as WallNode, space.polygon))
|
|
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
|
|
}
|
|
|
|
if (node.type === 'slab' && scope === 'room') {
|
|
const centroid = polygonCentroid((node as SlabNode).polygon)
|
|
if (!centroid) return single
|
|
const space = Object.values(spaces).find((candidate) =>
|
|
pointInPolygon2D(centroid, candidate.polygon),
|
|
)
|
|
if (!space) return single
|
|
return Object.values(nodes)
|
|
.filter((other) => {
|
|
if (other.type !== 'slab') return false
|
|
const otherCentroid = polygonCentroid((other as SlabNode).polygon)
|
|
return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
|
|
})
|
|
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
|
|
}
|
|
|
|
return single
|
|
}
|
|
|
|
// ── Batched commit ──────────────────────────────────────────────────────────
|
|
|
|
// Structural equality for the one-off-colour dedup below. The slot model is
|
|
// uniform across item / wall / slab (`node.slots[role] = ref`), so the same
|
|
// matcher the per-kind commits use applies to the whole fan-out.
|
|
function materialsEqual(a: unknown, b: unknown): boolean {
|
|
if (Object.is(a, b)) return true
|
|
if (typeof a !== typeof b || a === null || b === null) return false
|
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
return a.every((value, index) => materialsEqual(value, b[index]))
|
|
}
|
|
if (typeof a === 'object') {
|
|
const aRecord = a as Record<string, unknown>
|
|
const bRecord = b as Record<string, unknown>
|
|
const aKeys = Object.keys(aRecord)
|
|
if (aKeys.length !== Object.keys(bRecord).length) return false
|
|
return aKeys.every(
|
|
(key) => Object.hasOwn(bRecord, key) && materialsEqual(aRecord[key], bRecord[key]),
|
|
)
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Apply one paint to many slot-model targets in a single undo step. Resolves
|
|
* the slot ref ONCE — a one-off colour creates a single shared scene material
|
|
* for the whole fan-out, not one per node — then writes every `node.slots[role]`
|
|
* (or deletes it, for the eraser) in one `useScene.setState`. Only ever called
|
|
* for item / wall / slab fan-outs, all of which use the unified slot model.
|
|
*/
|
|
export function commitPaintScopeFanout(
|
|
targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>,
|
|
material: MaterialSchema | undefined,
|
|
materialPreset: string | undefined,
|
|
): void {
|
|
if (targets.length === 0) return
|
|
const state = useScene.getState()
|
|
|
|
let ref: string | undefined
|
|
let newSceneMaterial: SceneMaterial | null = null
|
|
if (material === undefined && materialPreset === undefined) {
|
|
ref = undefined // eraser → clear the slot back to its default
|
|
} else if (materialPreset) {
|
|
ref = materialPreset
|
|
} else if (material) {
|
|
const existing = Object.values(state.materials).find((scene) =>
|
|
materialsEqual(scene.material, material),
|
|
)
|
|
if (existing) {
|
|
ref = toSceneMaterialRef(existing.id)
|
|
} else {
|
|
const id = generateSceneMaterialId()
|
|
newSceneMaterial = {
|
|
id,
|
|
name: `Material ${Object.keys(state.materials).length + 1}`,
|
|
material,
|
|
}
|
|
ref = toSceneMaterialRef(id)
|
|
}
|
|
} else {
|
|
return
|
|
}
|
|
|
|
useScene.setState((current) => {
|
|
if (current.readOnly) return current
|
|
const nextNodes = { ...current.nodes }
|
|
let changed = false
|
|
for (const { nodeId, role } of targets) {
|
|
const node = nextNodes[nodeId] as SlotsNode | undefined
|
|
if (!node) continue
|
|
const nextSlots = { ...(node.slots ?? {}) }
|
|
if (ref) nextSlots[role] = ref
|
|
else delete nextSlots[role]
|
|
nextNodes[nodeId] = { ...node, slots: nextSlots } as AnyNode
|
|
changed = true
|
|
}
|
|
if (!changed) return current
|
|
return {
|
|
nodes: nextNodes,
|
|
materials: newSceneMaterial
|
|
? { ...current.materials, [newSceneMaterial.id as SceneMaterialId]: newSceneMaterial }
|
|
: current.materials,
|
|
}
|
|
})
|
|
|
|
for (const { nodeId } of targets) state.markDirty(nodeId)
|
|
}
|