diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc7825ff01..72ff0972fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,21 @@ export { polygonsOverlap, segmentsIntersect, } from './lib/polygon-relations' +export { + type Point2D as PolygonBooleanPoint2D, + subtractPolygonsFromPolygon, + unionPolygons, +} from './lib/polygon-union' +export { + compareRoofOverlapIdentity, + getRoofPlanBounds, + type RoofOverlapEntry, + type RoofPlan, + type RoofPlanBounds, + type RoofPlanSegment, + roofOverlapEntryOwns, + roofPlanBoundsOverlap, +} from './lib/roof-overlap' export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { getRenderableSlabPolygon, diff --git a/packages/viewer/src/lib/polygon-union.test.ts b/packages/core/src/lib/polygon-union.test.ts similarity index 99% rename from packages/viewer/src/lib/polygon-union.test.ts rename to packages/core/src/lib/polygon-union.test.ts index 0621d94f1a..d0c872eef4 100644 --- a/packages/viewer/src/lib/polygon-union.test.ts +++ b/packages/core/src/lib/polygon-union.test.ts @@ -1,4 +1,4 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// @ts-expect-error — bun:test is provided by the Bun runtime; core does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' import { type Point2D, subtractPolygonsFromPolygon, unionPolygons } from './polygon-union' diff --git a/packages/viewer/src/lib/polygon-union.ts b/packages/core/src/lib/polygon-union.ts similarity index 100% rename from packages/viewer/src/lib/polygon-union.ts rename to packages/core/src/lib/polygon-union.ts diff --git a/packages/core/src/lib/roof-overlap.test.ts b/packages/core/src/lib/roof-overlap.test.ts new file mode 100644 index 0000000000..8fc9a814c4 --- /dev/null +++ b/packages/core/src/lib/roof-overlap.test.ts @@ -0,0 +1,28 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; core does not depend on @types/bun. +import { describe, expect, test } from 'bun:test' +import { getRoofPlanBounds, roofOverlapEntryOwns, roofPlanBoundsOverlap } from './roof-overlap' + +describe('roof overlap', () => { + test('larger segments own intersections with stable ID tie-breaking', () => { + const current = { roofId: 'roof_b', segmentId: 'seg_b', width: 4, depth: 4 } + expect( + roofOverlapEntryOwns({ ...current, roofId: 'roof_a', segmentId: 'seg_a' }, current), + ).toBe(true) + expect(roofOverlapEntryOwns({ ...current, width: 5 }, current)).toBe(true) + expect(roofOverlapEntryOwns({ ...current, width: 3 }, current)).toBe(false) + }) + + test('computes rotated world bounds and rejects distant roofs', () => { + const bounds = getRoofPlanBounds({ + position: [10, 0, 4], + rotation: Math.PI / 2, + segments: [{ position: [0, 0, 0], rotation: 0, width: 6, depth: 2 }], + })! + expect(bounds.minX).toBeCloseTo(9) + expect(bounds.maxX).toBeCloseTo(11) + expect(bounds.minZ).toBeCloseTo(1) + expect(bounds.maxZ).toBeCloseTo(7) + expect(roofPlanBoundsOverlap(bounds, { minX: 10, minZ: 6, maxX: 12, maxZ: 8 })).toBe(true) + expect(roofPlanBoundsOverlap(bounds, { minX: 20, minZ: 20, maxX: 22, maxZ: 22 })).toBe(false) + }) +}) diff --git a/packages/core/src/lib/roof-overlap.ts b/packages/core/src/lib/roof-overlap.ts new file mode 100644 index 0000000000..aaec60551e --- /dev/null +++ b/packages/core/src/lib/roof-overlap.ts @@ -0,0 +1,95 @@ +export type RoofOverlapEntry = { + roofId: string + segmentId: string + width: number + depth: number +} + +export type RoofPlanBounds = { + minX: number + minZ: number + maxX: number + maxZ: number +} + +export type RoofPlanSegment = { + position: readonly [number, number, number] + rotation?: number + width: number + depth: number +} + +export type RoofPlan = { + position: readonly [number, number, number] + rotation?: number + segments: readonly RoofPlanSegment[] +} + +export function compareRoofOverlapIdentity(a: RoofOverlapEntry, b: RoofOverlapEntry): number { + const roofOrder = a.roofId.localeCompare(b.roofId) + return roofOrder !== 0 ? roofOrder : a.segmentId.localeCompare(b.segmentId) +} + +export function roofOverlapEntryOwns( + candidate: RoofOverlapEntry, + current: RoofOverlapEntry, + epsilon = 1e-6, +): boolean { + const candidateArea = candidate.width * candidate.depth + const currentArea = current.width * current.depth + return ( + candidateArea > currentArea + epsilon || + (Math.abs(candidateArea - currentArea) <= epsilon && + compareRoofOverlapIdentity(candidate, current) < 0) + ) +} + +export function getRoofPlanBounds(roof: RoofPlan): RoofPlanBounds | null { + if (roof.segments.length === 0) return null + const roofRotation = roof.rotation ?? 0 + const roofCos = Math.cos(roofRotation) + const roofSin = Math.sin(roofRotation) + const bounds: RoofPlanBounds = { + minX: Number.POSITIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY, + } + + for (const segment of roof.segments) { + const segmentRotation = segment.rotation ?? 0 + const segmentCos = Math.cos(segmentRotation) + const segmentSin = Math.sin(segmentRotation) + const halfWidth = Math.max(0, segment.width) / 2 + const halfDepth = Math.max(0, segment.depth) / 2 + for (const [x, z] of [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] as const) { + const roofX = segment.position[0] + x * segmentCos + z * segmentSin + const roofZ = segment.position[2] - x * segmentSin + z * segmentCos + const worldX = roof.position[0] + roofX * roofCos + roofZ * roofSin + const worldZ = roof.position[2] - roofX * roofSin + roofZ * roofCos + bounds.minX = Math.min(bounds.minX, worldX) + bounds.minZ = Math.min(bounds.minZ, worldZ) + bounds.maxX = Math.max(bounds.maxX, worldX) + bounds.maxZ = Math.max(bounds.maxZ, worldZ) + } + } + return bounds +} + +export function roofPlanBoundsOverlap( + a: RoofPlanBounds, + b: RoofPlanBounds, + epsilon = 1e-6, +): boolean { + return !( + a.maxX < b.minX - epsilon || + b.maxX < a.minX - epsilon || + a.maxZ < b.minZ - epsilon || + b.maxZ < a.minZ - epsilon + ) +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 8596a79bde..8ab8725948 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -54,7 +54,7 @@ export { getBlockFaceNormal, inspectBlockTopology, } from './nodes/block' -export { BoxVentNode } from './nodes/box-vent' +export { BoxVentMaterialRole, BoxVentNode } from './nodes/box-vent' export { BuildingNode } from './nodes/building' export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' export { CeilingNode } from './nodes/ceiling' @@ -94,7 +94,7 @@ export { setConstructionDimensionDrawingPresentation, setConstructionDimensionDrawingSuppressedSegments, } from './nodes/construction-dimension' -export { CupolaNode } from './nodes/cupola' +export { CupolaMaterialRole, CupolaNode } from './nodes/cupola' export { DoorNode, DoorSegment, @@ -122,7 +122,7 @@ export { ElevatorNode, ElevatorShaftStyle, } from './nodes/elevator' -export { EyebrowVentNode } from './nodes/eyebrow-vent' +export { EyebrowVentMaterialRole, EyebrowVentNode } from './nodes/eyebrow-vent' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export { @@ -225,6 +225,7 @@ export { } from './nodes/roof-segment' export type { DutchRoofShapeMetrics, + RoofShapeEaveSide, RoofShapeFaceVertex, RoofShapeInsets, RoofShapeRatios, @@ -233,6 +234,7 @@ export { getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getRoofModuleFaces, + getRoofShapeEaveSides, getRoofShapeInsets, getRoofShapeRatios, } from './nodes/roof-segment-shape' @@ -278,7 +280,7 @@ export { export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { StructuralGridNode } from './nodes/structural-grid' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' -export { TurbineVentNode } from './nodes/turbine-vent' +export { TurbineVentMaterialRole, TurbineVentNode } from './nodes/turbine-vent' export type { WallBandSurfaceSlotId, WallFaceBand, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 5a07a96646..f1a3cf20c3 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -63,6 +63,7 @@ export const MaterialTarget = z.enum([ 'cupola', 'eyebrow-vent', 'gutter', + 'downspout', ]) export type MaterialTarget = z.infer diff --git a/packages/core/src/schema/nodes/box-vent.ts b/packages/core/src/schema/nodes/box-vent.ts index 23e27a59bd..124ade6f73 100644 --- a/packages/core/src/schema/nodes/box-vent.ts +++ b/packages/core/src/schema/nodes/box-vent.ts @@ -3,11 +3,15 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const BoxVentMaterialRole = z.enum(['base', 'top']) +export type BoxVentMaterialRole = z.infer + export const BoxVentNode = BaseNode.extend({ id: objectId('bvent'), type: nodeType('box-vent'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so newly-placed vents read as clean // painted metal — and so the paint inspector shows "White" as the // current selection instead of an empty "no material" state, which diff --git a/packages/core/src/schema/nodes/cupola.ts b/packages/core/src/schema/nodes/cupola.ts index 6e8c12a795..98d3805883 100644 --- a/packages/core/src/schema/nodes/cupola.ts +++ b/packages/core/src/schema/nodes/cupola.ts @@ -3,11 +3,15 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const CupolaMaterialRole = z.enum(['base', 'body', 'roof']) +export type CupolaMaterialRole = z.infer + export const CupolaNode = BaseNode.extend({ id: objectId('cupola'), type: nodeType('cupola'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so a freshly-placed cupola reads as clean // painted metal and the paint inspector shows "White" (matches box-vent). materialPreset: z.string().default('preset-white'), diff --git a/packages/core/src/schema/nodes/downspout.ts b/packages/core/src/schema/nodes/downspout.ts index 174e3de092..d89ac96b44 100644 --- a/packages/core/src/schema/nodes/downspout.ts +++ b/packages/core/src/schema/nodes/downspout.ts @@ -10,6 +10,7 @@ export const DownspoutNode = BaseNode.extend({ type: nodeType('downspout'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Match the gutter family default — paint inspector reads "White" // instead of "no material" on a freshly placed downspout. materialPreset: z.string().default('preset-white'), diff --git a/packages/core/src/schema/nodes/eyebrow-vent.ts b/packages/core/src/schema/nodes/eyebrow-vent.ts index 0797882fe3..e77074899e 100644 --- a/packages/core/src/schema/nodes/eyebrow-vent.ts +++ b/packages/core/src/schema/nodes/eyebrow-vent.ts @@ -3,11 +3,15 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const EyebrowVentMaterialRole = z.enum(['hood', 'front']) +export type EyebrowVentMaterialRole = z.infer + export const EyebrowVentNode = BaseNode.extend({ id: objectId('eyebrow-vent'), type: nodeType('eyebrow-vent'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so a freshly-placed vent reads as clean // painted metal and the paint inspector shows "White" (matches box-vent). materialPreset: z.string().default('preset-white'), diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index 5852ebb9c4..3372108fa8 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { normalizeRoofSegmentTrim, type RoofSegmentNode } from './roof-segment' +import { getRoofShapeEaveSides } from './roof-segment-shape' const MIN_DEFAULT_GUTTER_LENGTH_M = 0.2 const DEFAULT_GUTTER_GENERATOR = 'default-gutter' @@ -50,6 +51,7 @@ export const GutterNode = BaseNode.extend({ type: nodeType('gutter'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // White preset by default — matches the rest of the roof accessory // family (box-vent / ridge-vent) so the paint inspector reads as // "White" instead of "no material" on a freshly-placed gutter. @@ -143,15 +145,7 @@ export function computeGutterEaveY( } function getDefaultGutterSides(segment: RoofSegmentNode): GutterEaveSide[] { - switch (segment.roofType) { - case 'shed': - return ['+Z'] - case 'gable': - case 'gambrel': - return ['+Z', '-Z'] - default: - return ['+Z', '-Z', '+X', '-X'] - } + return getRoofShapeEaveSides(segment.roofType) } function getGutterEnvelope(segment: RoofSegmentNode) { diff --git a/packages/core/src/schema/nodes/roof-segment-shape.ts b/packages/core/src/schema/nodes/roof-segment-shape.ts index ac1d5787db..d4d7b2db45 100644 --- a/packages/core/src/schema/nodes/roof-segment-shape.ts +++ b/packages/core/src/schema/nodes/roof-segment-shape.ts @@ -6,6 +6,20 @@ export type RoofShapeFaceVertex = { z: number } +export type RoofShapeEaveSide = '+X' | '-X' | '+Z' | '-Z' + +export function getRoofShapeEaveSides(type: RoofType): RoofShapeEaveSide[] { + switch (type) { + case 'shed': + return ['+Z'] + case 'gable': + case 'gambrel': + return ['+Z', '-Z'] + default: + return ['+Z', '-Z', '+X', '-X'] + } +} + export type RoofShapeInsets = { iF?: number iB?: number diff --git a/packages/core/src/schema/nodes/turbine-vent.ts b/packages/core/src/schema/nodes/turbine-vent.ts index d828fe4cda..3fbc5cbb41 100644 --- a/packages/core/src/schema/nodes/turbine-vent.ts +++ b/packages/core/src/schema/nodes/turbine-vent.ts @@ -3,11 +3,15 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const TurbineVentMaterialRole = z.enum(['base', 'head']) +export type TurbineVentMaterialRole = z.infer + export const TurbineVentNode = BaseNode.extend({ id: objectId('tvent'), type: nodeType('turbine-vent'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so a freshly-placed turbine reads as // clean painted/galvanised metal and the paint inspector shows "White" // as the current selection (matches box-vent's reasoning). diff --git a/packages/core/src/store/use-scene-wall-slot-migration.test.ts b/packages/core/src/store/use-scene-wall-slot-migration.test.ts index ef1e90af82..76e0f647d5 100644 --- a/packages/core/src/store/use-scene-wall-slot-migration.test.ts +++ b/packages/core/src/store/use-scene-wall-slot-migration.test.ts @@ -261,4 +261,35 @@ describe('procedural kind surface-material → slots migration', () => { expect(slab.slots).toBeUndefined() expect(Object.keys(useScene.getState().materials)).toHaveLength(0) }) + + test('roof accessory role materials migrate to their matching slots', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'box-vent', + baseMaterialPreset: 'library:metal-steel', + topMaterialPreset: 'library:metal-copper', + }), + ['site_test'] as never, + ) + + const vent = (useScene.getState().nodes as Record).node_test! + expect(vent.slots).toEqual({ + base: 'library:metal-steel', + top: 'library:metal-copper', + }) + expect((vent as { baseMaterialPreset?: unknown }).baseMaterialPreset).toBeUndefined() + expect((vent as { topMaterialPreset?: unknown }).topMaterialPreset).toBeUndefined() + }) + + test('gutter and downspout legacy paint migrates to the surface slot', () => { + for (const type of ['gutter', 'downspout'] as const) { + useScene + .getState() + .setScene(sceneWithNode({ type, materialPreset: 'library:metal-steel' }), [ + 'site_test', + ] as never) + const node = (useScene.getState().nodes as Record).node_test! + expect(node.slots).toEqual({ surface: 'library:metal-steel' }) + } + }) }) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 3481bab410..cd5f23a772 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -381,6 +381,37 @@ function migrateSingleMaterialSlots( return { ...node, slots, material: undefined, materialPreset: undefined } } +function migrateRoleMaterialSlots( + node: Record, + roles: readonly string[], + mintedMaterials: Record, +) { + const slots: Record = { ...(node.slots ?? {}) } + const next = { ...node } + let changed = false + + for (const role of roles) { + if (slots[role] === undefined) { + const ref = legacySpecToMaterialRef( + { + material: node[`${role}Material`] ?? node.material, + materialPreset: node[`${role}MaterialPreset`] ?? node.materialPreset, + }, + mintedMaterials, + ) + if (ref) { + slots[role] = ref + changed = true + } + } + if (`${role}Material` in next || `${role}MaterialPreset` in next) changed = true + delete next[`${role}Material`] + delete next[`${role}MaterialPreset`] + } + + return changed ? { ...next, slots } : node +} + // Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` / // `railingMaterial*`) plus a catch-all. Map each to its slot via the same // fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`): @@ -838,6 +869,42 @@ function migrateNodes(nodes: Record): { ) } + if (node.type === 'gutter' || node.type === 'downspout') { + patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) + } + + if (node.type === 'box-vent') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['base', 'top'], + mintedMaterials, + ) + } + + if (node.type === 'cupola') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['base', 'body', 'roof'], + mintedMaterials, + ) + } + + if (node.type === 'eyebrow-vent') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['hood', 'front'], + mintedMaterials, + ) + } + + if (node.type === 'turbine-vent') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['base', 'head'], + mintedMaterials, + ) + } + if (node.type === 'shelf') { const normalized = normalizeShelfNode(node) if (normalized) { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d37ff43219..2fef770bc7 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -4673,6 +4673,7 @@ function FloorplanLinearDraftLayer({ const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd) const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd) const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) + const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn) const draftPolygon = useMemo(() => { if ( @@ -4709,6 +4710,21 @@ function FloorplanLinearDraftLayer({ return draftPolygon ? formatPolygonPoints(draftPolygon) : null }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart]) + const roofDraftDirectionLine = useMemo(() => { + if (!(isRoofBuildActive && roofDraftStart && roofDraftEnd)) return null + const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) + const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0]) + const minY = Math.min(roofDraftStart[1], roofDraftEnd[1]) + const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1]) + if (maxX - minX < 1e-6 || maxY - minY < 1e-6) return null + + const centerX = (minX + maxX) / 2 + const centerY = (minY + maxY) / 2 + return roofDraftQuarterTurn + ? { x1: centerX, y1: minY, x2: centerX, y2: maxY } + : { x1: minX, y1: centerY, x2: maxX, y2: centerY } + }, [isRoofBuildActive, roofDraftEnd, roofDraftQuarterTurn, roofDraftStart]) + const fenceDraftSegment = useMemo(() => { if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) { return null @@ -4887,6 +4903,19 @@ function FloorplanLinearDraftLayer({ unitsPerPixel={unitsPerPixel} /> + {roofDraftDirectionLine && ( + + )} + {draftWallMeasurement && ( { useEffect(() => { const nodes = useScene.getState().nodes - // Roofs where a segment itself is selected -> full edit mode (hide - // merged, show wrapper). + // Roofs where a segment itself is selected enter full edit mode. const activeRoofIds = new Set() // Roofs where an accessory (dormer/chimney/etc.) is selected -> only // reveal the wrapper so handle portals into the segment mesh become // visible. Merged stays on. const revealRoofIds = new Set() - // Roofs whose selected segment is currently being moved in 3D. During this - // transient state we reveal the wrapper so the moving segment mesh is - // visible and hide the merged roof to avoid the duplicate shell fighting it. + // Roofs whose selected segment is currently being moved in 3D. The merged + // roof remains the visual source and rebuilds from the live move override. const movingRoofIds = new Set() for (const id of selectedIds) { @@ -1842,14 +1840,8 @@ export const RoofEditSystem = () => { const isMoving = movingRoofIds.has(roofId) const isReveal = revealRoofIds.has(roofId) - // Keep the clean merged shell visible during trim editing too (not just - // when deselected). The merged shell rebuilds live from each segment's - // trim override (RoofSystem reads getEffectiveNode), so the dragged - // cutaway matches the commit. Showing the individual per-segment meshes - // instead would expose their abutting end-cap faces (the white planes the - // merged union removes) — exactly what the commit doesn't show. - if (mergedMesh) mergedMesh.visible = !isMoving - if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving + if (mergedMesh) mergedMesh.visible = true + if (segmentsWrapper) segmentsWrapper.visible = isReveal const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined if (roofNode?.children?.length) { @@ -1857,10 +1849,8 @@ export const RoofEditSystem = () => { const wasMoving = prevMovingRoofIds.current.has(roofId) const wasReveal = prevRevealRoofIds.current.has(roofId) if (isActive !== wasActive || isMoving !== wasMoving) { - // Entering / exiting full edit mode: rebuild segment / merged - // geometries. Segment-move reveal uses the same rebuild so any - // wrapper mesh previously stripped to an empty placeholder is - // restored before the drag begins. + // Entering or exiting edit and move modes rebuilds the merged shell + // from the current segment values. const { markDirty } = useScene.getState() for (const childId of roofNode.children) { markDirty(childId as AnyNodeId) diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 703dde667a..5eec0908ea 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -38,6 +38,19 @@ const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 const GRID_OFFSET = 0.02 +function resolveRoofDraftPlacement( + footprintWidth: number, + footprintDepth: number, + quarterTurn: boolean, + parentRotation = 0, +) { + return { + width: quarterTurn ? footprintDepth : footprintWidth, + depth: quarterTurn ? footprintWidth : footprintDepth, + rotation: -parentRotation + (quarterTurn ? Math.PI / 2 : 0), + } +} + // Walls that are direct children of a level. function getLevelWalls( levelId: string | null, @@ -106,6 +119,7 @@ const commitRoofPlacement = ( corner1: [number, number, number], corner2: [number, number, number], selectedIds: string[], + quarterTurn: boolean, ): AnyNode['id'] => { const { createNode, createNodes, nodes } = useScene.getState() @@ -119,8 +133,8 @@ const commitRoofPlacement = ( const centerX = (corner1[0] + corner2[0]) / 2 const centerZ = (corner1[2] + corner2[2]) / 2 - const width = Math.max(Math.abs(corner2[0] - corner1[0]), 1) - const depth = Math.max(Math.abs(corner2[2] - corner1[2]), 1) + const footprintWidth = Math.max(Math.abs(corner2[0] - corner1[0]), 1) + const footprintDepth = Math.max(Math.abs(corner2[2] - corner1[2]), 1) // Determine if there is an active roof node we should add to let targetRoofId: RoofNode['id'] | null = null @@ -155,14 +169,22 @@ const commitRoofPlacement = ( localZ = dx * Math.sin(angle) + dz * Math.cos(angle) } + const placement = resolveRoofDraftPlacement( + footprintWidth, + footprintDepth, + quarterTurn, + targetRoof.rotation, + ) + const segment = RoofSegmentNode.parse({ wallHeight: DEFAULT_WALL_HEIGHT, pitch: DEFAULT_PITCH_DEG, roofType: 'gable', ...defaults, - width, - depth, + width: placement.width, + depth: placement.depth, position: [localX, 0, localZ], + rotation: placement.rotation, }) createNode(segment, targetRoofId as AnyNode['id']) @@ -173,6 +195,13 @@ const commitRoofPlacement = ( // Count existing roofs for naming const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length const name = `Roof ${roofCount + 1}` + const roofRotation = typeof defaults.rotation === 'number' ? defaults.rotation : 0 + const placement = resolveRoofDraftPlacement( + footprintWidth, + footprintDepth, + quarterTurn, + roofRotation, + ) // Create the segment first (centered in its new parent) const segment = RoofSegmentNode.parse({ @@ -180,9 +209,10 @@ const commitRoofPlacement = ( pitch: DEFAULT_PITCH_DEG, roofType: 'gable', ...defaults, - width, - depth, + width: placement.width, + depth: placement.depth, position: [0, 0, 0], + rotation: placement.rotation, }) // Create the roof container. Segment-shaped params (roofType, pitch, …) are @@ -384,6 +414,8 @@ export const RoofTool: React.FC = () => { const corner1Ref = useRef<[number, number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) + const quarterTurnRef = useRef(false) + const [quarterTurn, setQuarterTurn] = useState(false) const [preview, setPreview] = useState({ corner1: null, cursorPosition: [0, 0, 0], @@ -394,6 +426,7 @@ export const RoofTool: React.FC = () => { if (!currentLevelId) return outlineRef.current.geometry = new BufferGeometry() + useFloorplanDraftPreview.getState().setRoofDraftQuarterTurn(quarterTurnRef.current) // Alignment candidates — anchors of every alignable object on the active // level plus the wall corners of the floor directly below, so a roof drawn @@ -496,6 +529,7 @@ export const RoofTool: React.FC = () => { corner1Ref.current, [gridX, y, gridZ], selectedIdsRef.current, + quarterTurnRef.current, ) setSelection({ selectedIds: [roofId as AnyNode['id']] }) @@ -533,20 +567,49 @@ export const RoofTool: React.FC = () => { clearSurfacePlanSnapFeedback() } + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } + if ( + (event.key !== 'r' && event.key !== 'R') || + event.repeat || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + return + } + + event.preventDefault() + const nextQuarterTurn = !quarterTurnRef.current + quarterTurnRef.current = nextQuarterTurn + setQuarterTurn(nextQuarterTurn) + useFloorplanDraftPreview.getState().setRoofDraftQuarterTurn(nextQuarterTurn) + sfxEmitter.emit('sfx:item-rotate') + } + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) clearSurfacePlanSnapFeedback() corner1Ref.current = null const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setRoofDraftStart(null) draftPreview.setRoofDraftEnd(null) + draftPreview.setRoofDraftQuarterTurn(false) } }, [currentLevelId, setSelection]) @@ -563,23 +626,33 @@ export const RoofTool: React.FC = () => { const roofGhostGeometry = useMemo(() => { if (!previewDimensions) return null - return buildRoofGhostGeometry( + const placement = resolveRoofDraftPlacement( previewDimensions.length, previewDimensions.width, + quarterTurn, + ) + return buildRoofGhostGeometry( + placement.width, + placement.depth, DEFAULT_WALL_HEIGHT, DEFAULT_PITCH_DEG, ) - }, [previewDimensions]) + }, [previewDimensions, quarterTurn]) const roofGhostEdges = useMemo(() => { if (!previewDimensions) return null - return buildRoofGhostEdges( + const placement = resolveRoofDraftPlacement( previewDimensions.length, previewDimensions.width, + quarterTurn, + ) + return buildRoofGhostEdges( + placement.width, + placement.depth, DEFAULT_WALL_HEIGHT, DEFAULT_PITCH_DEG, ) - }, [previewDimensions]) + }, [previewDimensions, quarterTurn]) useEffect( () => () => { @@ -625,6 +698,7 @@ export const RoofTool: React.FC = () => { {roofGhostGeometry && ( diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx index 3056f5fe42..3fc89249f8 100644 --- a/packages/editor/src/components/ui/helpers/roof-helper.tsx +++ b/packages/editor/src/components/ui/helpers/roof-helper.tsx @@ -6,6 +6,7 @@ export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null } { + // True while an active placement tool owns R/T. Door/window tools flip the + // draft and the roof tool turns its draft axes, so the global + // selection-based handler must stand down to avoid double-firing. + const isToolOwnedRotation = () => { const ed = useEditor.getState() const moving = getMovingNode() if (moving?.type === 'door' || moving?.type === 'window') return true - return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') + return ( + ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window' || ed.tool === 'roof') + ) } // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step) @@ -480,7 +481,7 @@ export const useKeyboard = ({ !e.metaKey && !e.ctrlKey && !isVersionPreviewMode && - !isPlacingOpening() + !isToolOwnedRotation() ) { // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead // of rotating/flipping the selected node. @@ -489,10 +490,9 @@ export const useKeyboard = ({ // open/close toggle lives on E. Windows still use R to toggle // their open/closed state. // - // Skipped entirely while a door/window placement is active - // (`isPlacingOpening`): the placement tool owns R then (flip the draft - // before commit), and the user can have a node selected at the same - // time — without this guard both would fire (double flip + sfx). + // Skipped entirely while a door/window placement or roof draft is active: + // those tools own R, and the user can have a node selected at the same + // time. Without this guard both the draft and selection would rotate. // // References (guide/scan) live in `selectedReferenceId`, not the viewer // selection — check them first, like the Delete arm below. @@ -572,7 +572,11 @@ export const useKeyboard = ({ sfxEmitter.emit('sfx:item-rotate') } } - } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) { + } else if ( + (e.key === 't' || e.key === 'T') && + !isVersionPreviewMode && + !isToolOwnedRotation() + ) { // Rotate selected node counter-clockwise // Multi-selection → group rotate, mirroring the R arm above. if (rotateGroupSelection(-1)) { diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 7c0af3e70f..f0b097bc77 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -47,6 +47,8 @@ export type PaintableMaterialTarget = | 'turbine-vent' | 'cupola' | 'eyebrow-vent' + | 'gutter' + | 'downspout' > | 'item' diff --git a/packages/editor/src/store/use-floorplan-draft-preview.ts b/packages/editor/src/store/use-floorplan-draft-preview.ts index 8414b82c76..62b6bdc7cc 100644 --- a/packages/editor/src/store/use-floorplan-draft-preview.ts +++ b/packages/editor/src/store/use-floorplan-draft-preview.ts @@ -40,6 +40,7 @@ type FloorplanDraftPreviewState = { wallDraftStart: WallPlanPoint | null fenceDraftStart: WallPlanPoint | null roofDraftStart: WallPlanPoint | null + roofDraftQuarterTurn: boolean polygonDraftType: FloorplanPolygonDraftType | null polygonDraftPoints: WallPlanPoint[] /** Set the snapped cursor point. No-ops (skips the store update, so @@ -54,6 +55,7 @@ type FloorplanDraftPreviewState = { setWallDraftStart(point: WallPlanPoint | null): void setFenceDraftStart(point: WallPlanPoint | null): void setRoofDraftStart(point: WallPlanPoint | null): void + setRoofDraftQuarterTurn(quarterTurn: boolean): void setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void reset(): void } @@ -94,6 +96,7 @@ export const useFloorplanDraftPreview = create((set) wallDraftStart: null, fenceDraftStart: null, roofDraftStart: null, + roofDraftQuarterTurn: false, polygonDraftType: null, polygonDraftPoints: [], setCursorPoint: (point) => @@ -116,6 +119,10 @@ export const useFloorplanDraftPreview = create((set) setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)), setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)), setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)), + setRoofDraftQuarterTurn: (quarterTurn) => + set((state) => + state.roofDraftQuarterTurn === quarterTurn ? state : { roofDraftQuarterTurn: quarterTurn }, + ), setPolygonDraft: (type, points) => set((state) => state.polygonDraftType === type && planPointsEqual(state.polygonDraftPoints, points) @@ -132,6 +139,7 @@ export const useFloorplanDraftPreview = create((set) state.wallDraftStart === null && state.fenceDraftStart === null && state.roofDraftStart === null && + state.roofDraftQuarterTurn === false && state.polygonDraftType === null && state.polygonDraftPoints.length === 0 ? state @@ -144,6 +152,7 @@ export const useFloorplanDraftPreview = create((set) wallDraftStart: null, fenceDraftStart: null, roofDraftStart: null, + roofDraftQuarterTurn: false, polygonDraftType: null, polygonDraftPoints: [], }, diff --git a/packages/nodes/src/box-vent/__tests__/geometry.test.ts b/packages/nodes/src/box-vent/__tests__/geometry.test.ts index 30ad034879..a57ba4a5ea 100644 --- a/packages/nodes/src/box-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/box-vent/__tests__/geometry.test.ts @@ -22,6 +22,22 @@ describe('buildBoxVentGeometry', () => { expect(box.getAttribute('position').count).toBe(384) }) + test.each([ + 'box', + 'cap', + 'dome', + ] as const)('%s style separates the lower base from the upper cover', (style) => { + const geometry = buildBoxVentGeometry(BoxVentNode.parse({ style })) + const vertexCount = geometry.getAttribute('position').count + + expect(geometry.groups).toHaveLength(2) + expect(geometry.groups[0]).toMatchObject({ start: 0, materialIndex: 0 }) + expect(geometry.groups[1]).toMatchObject({ materialIndex: 1 }) + expect(geometry.groups[0]!.count).toBeGreaterThan(0) + expect(geometry.groups[1]!.count).toBeGreaterThan(0) + expect(geometry.groups[0]!.count + geometry.groups[1]!.count).toBe(vertexCount) + }) + test('box style: zero bevel still produces a valid closed solid', () => { // With bevel=0 the wall-edge dedupe drops the degenerate corner // quads, but the bottom + top fan triangulations always include @@ -91,6 +107,17 @@ describe('buildBoxVentGeometry', () => { expect(maxX).toBeCloseTo(0.3) expect(maxZ).toBeCloseTo(0.25) }) + + test('unwraps the rounded base and cover at metre scale', () => { + const geometry = buildBoxVentGeometry( + BoxVentNode.parse({ style: 'box', width: 2, depth: 1.5, height: 0.6 }), + ) + const uv = geometry.getAttribute('uv') + expect(geometry.getAttribute('uv2').count).toBe(uv.count) + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(5) + }) }) describe('computeBoxVentSlopeTilt', () => { diff --git a/packages/nodes/src/box-vent/__tests__/paint.test.ts b/packages/nodes/src/box-vent/__tests__/paint.test.ts new file mode 100644 index 0000000000..e8cf1d0ecb --- /dev/null +++ b/packages/nodes/src/box-vent/__tests__/paint.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import { Group, Mesh, MeshBasicMaterial } from 'three' +import { boxVentPaint, resolveBoxVentMaterialRole } from '../paint' +import { BoxVentNode } from '../schema' + +describe('box vent paint', () => { + test('maps geometry material groups to base and top roles', () => { + expect(resolveBoxVentMaterialRole(0)).toBe('base') + expect(resolveBoxVentMaterialRole(1)).toBe('top') + }) + + test('updates only the painted role', () => { + const node = BoxVentNode.parse({ slots: { base: 'library:metal-steel' } }) + expect( + boxVentPaint.buildPatch({ + node, + role: 'top', + material: undefined, + materialPreset: 'library:roof-shingle', + }), + ).toEqual({ + slots: { base: 'library:metal-steel', top: 'library:roof-shingle' }, + }) + }) + + test('keeps the legacy whole-vent material as an independent fallback', () => { + const node = BoxVentNode.parse({ materialPreset: 'preset-white' }) + expect( + boxVentPaint.getEffectiveMaterial?.({ node, role: 'top', nodes: {} })?.materialPreset, + ).toBe('preset-white') + }) + + test('previews the top without replacing the base material', () => { + const base = new MeshBasicMaterial() + const top = new MeshBasicMaterial() + const mesh = new Mesh(undefined, [base, top]) + mesh.name = 'box-vent-surface' + const root = new Group() + root.add(mesh) + + const restore = boxVentPaint.applyPreview({ + node: BoxVentNode.parse({}), + role: 'top', + material: { + preset: 'custom', + properties: { + color: '#123456', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + materialPreset: undefined, + root, + }) + + expect(Array.isArray(mesh.material)).toBe(true) + expect(mesh.material[0]).toBe(base) + expect(mesh.material[1]).not.toBe(top) + + restore?.() + expect(mesh.material).toEqual([base, top]) + }) +}) diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index fef3b1b0f2..4ef4295c2e 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -4,8 +4,8 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildBoxVentFloorplan } from './floorplan' +import { boxVentPaint } from './paint' import { boxVentParametrics } from './parametrics' import { BoxVentNode } from './schema' @@ -175,7 +175,7 @@ const boxVentHandles: HandleDescriptor[] = [ */ export const boxVentDefinition: NodeDefinition = { kind: 'box-vent', - schemaVersion: 1, + schemaVersion: 3, schema: BoxVentNode, category: 'structure', surfaceRole: 'roof', @@ -187,11 +187,14 @@ export const boxVentDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'base', label: 'Base', default: 'library:preset-softwhite' }, + { slotId: 'top', label: 'Top', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: boxVentPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // slope — no `buildCut`, just the dirty cascade so the parent // roof's merged shell rebuilds when the vent moves / resizes. diff --git a/packages/nodes/src/box-vent/geometry.ts b/packages/nodes/src/box-vent/geometry.ts index 20cc59a08b..dee0ec1521 100644 --- a/packages/nodes/src/box-vent/geometry.ts +++ b/packages/nodes/src/box-vent/geometry.ts @@ -1,5 +1,11 @@ import { type BoxVentNode, getActiveRoofHeight, type RoofType } from '@pascal-app/core' import * as THREE from 'three' +import { copyUvToSecondaryChannel } from '../shared/primitive-uv' + +export const BOX_VENT_MATERIAL_INDEX = { + base: 0, + top: 1, +} as const /** * Pure builder for the box-vent mesh. Models a real attic box vent: @@ -68,11 +74,12 @@ function buildBoxShape(node: BoxVentNode): THREE.BufferGeometry { // Lower (smaller) riser. Top is hidden under the cover but include // it anyway — overlap is invisible and the geometry stays simple. buildRoundedExtrusion(positions, normals, uvs, baseW, baseD, 0, baseH, cornerBevel) + const topStartVertex = positions.length / 3 // Upper (larger) cover. Bottom partially shows where it overhangs the // riser, so it's always rendered. buildRoundedExtrusion(positions, normals, uvs, w, d, baseH, h, cornerBevel) - return buildBufferGeometry(positions, normals, uvs) + return buildBufferGeometry(positions, normals, uvs, topStartVertex) } // Extruded rounded rectangle: walls follow a rounded-rect profile, @@ -90,6 +97,7 @@ function buildRoundedExtrusion( ): void { const profile = roundedRectProfile(w, d, bevel, BOX_CORNER_SEGS) const n = profile.length + let perimeterU = 0 // Walls: each edge in the closed profile becomes an outward-facing quad. for (let i = 0; i < n; i++) { @@ -110,7 +118,9 @@ function buildRoundedExtrusion( [b.x, y1, b.z], [a.x, y1, a.z], [nx, 0, nz], + perimeterU, ) + perimeterU += len } // Top cap (+Y normal): wind triangles CW from above so the cross @@ -118,14 +128,14 @@ function buildRoundedExtrusion( for (let i = 0; i < n; i++) { const a = profile[i]! const b = profile[(i + 1) % n]! - pushTri(positions, normals, uvs, [0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z], [0, 1, 0]) + pushTri(positions, normals, uvs, [0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z], [0, 1, 0], 'xz') } // Bottom cap (-Y normal): wind CCW from above. for (let i = 0; i < n; i++) { const a = profile[i]! const b = profile[(i + 1) % n]! - pushTri(positions, normals, uvs, [0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z], [0, -1, 0]) + pushTri(positions, normals, uvs, [0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z], [0, -1, 0], 'xz') } } @@ -286,6 +296,8 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry { ) } + const topStartVertex = positions.length / 3 + // ── Flange underside (the bit of the cap base that overhangs the body) if (overhang > 0 || capGap > 0) { pushQuad( @@ -364,7 +376,7 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry { [0, 1, 0], ) - return buildBufferGeometry(positions, normals, uvs) + return buildBufferGeometry(positions, normals, uvs, topStartVertex) } function clamp01(value: number): number { @@ -432,8 +444,10 @@ function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry { addBand(positions, normals, uvs, flangeBottom, center, lng, down) addBand(positions, normals, uvs, flangeBottom, flangeTop, lng, radial) addBand(positions, normals, uvs, flangeTop, collarFoot, lng, up) - // Lifted collar wall (radial) + the overhanging dome-lip underside (down). + // Lifted collar wall (radial). addBand(positions, normals, uvs, collarFoot, collarTop, lng, radial) + const topStartVertex = positions.length / 3 + // The overhanging dome-lip underside belongs to the upper cover. addBand(positions, normals, uvs, collarTop, domeBase, lng, down) // Dome cap, base ring → apex. @@ -446,16 +460,17 @@ function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry { return [x / l, y / l, z / l] } let prev = domeBase + let domeV = 0 for (let i = 1; i <= lat; i++) { const phi = (Math.PI / 2) * (i / lat) const rf = Math.cos(phi) ** power const y = domeBaseY + domeH * Math.sin(phi) const ring = ringAt(rx * rf, rz * rf, y, lng) - addBand(positions, normals, uvs, prev, ring, lng, domeHint) + domeV += addBand(positions, normals, uvs, prev, ring, lng, domeHint, domeV) prev = ring } - return buildBufferGeometry(positions, normals, uvs) + return buildBufferGeometry(positions, normals, uvs, topStartVertex) } // One ellipse ring of `lng` segments at height `y`. First and last points @@ -479,14 +494,20 @@ function addBand( rB: number[][], lng: number, hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[], -): void { + vOffset = 0, +): number { + let uOffset = 0 + let vStep = 0 for (let j = 0; j < lng; j++) { const a = rA[j]! const b = rA[j + 1]! const c = rB[j + 1]! const d = rB[j]! - pushQuadOriented(positions, normals, uvs, a, b, c, d, hintFn(a, b, c, d)) + pushQuadOriented(positions, normals, uvs, a, b, c, d, hintFn(a, b, c, d), uOffset, vOffset) + uOffset += Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) + vStep += Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) } + return vStep / lng } // Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so @@ -500,6 +521,8 @@ function pushQuadOriented( c: number[], d: number[], hint: number[], + uOffset = 0, + vOffset = 0, ) { let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!) let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!) @@ -515,19 +538,18 @@ function pushQuadOriented( ny /= len nz /= len - const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) - const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) + const quadUvs = surfaceQuadUvs(a, b, c, d, [nx, ny, nz], uOffset, vOffset) if (flip) { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, v) + uvs.push(...quadUvs.a, ...quadUvs.b, ...quadUvs.c) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, v, 0, v) + uvs.push(...quadUvs.a, ...quadUvs.c, ...quadUvs.d) } else { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...quadUvs.a, ...quadUvs.c, ...quadUvs.b) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...quadUvs.a, ...quadUvs.d, ...quadUvs.c) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -538,11 +560,16 @@ function buildBufferGeometry( positions: number[], normals: number[], uvs: number[], + topStartVertex: number, ): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + const vertexCount = positions.length / 3 + geo.addGroup(0, topStartVertex, BOX_VENT_MATERIAL_INDEX.base) + geo.addGroup(topStartVertex, vertexCount - topStartVertex, BOX_VENT_MATERIAL_INDEX.top) + copyUvToSecondaryChannel(geo) return geo } @@ -555,35 +582,55 @@ function pushQuad( c: number[], d: number[], n: number[], + uOffset = 0, ) { const nLen = Math.sqrt(n[0]! * n[0]! + n[1]! * n[1]! + n[2]! * n[2]!) || 1 const nx = n[0]! / nLen const ny = n[1]! / nLen const nz = n[2]! / nLen - // Dimension-based planar UVs: U follows |b-a| (the quad's "right" - // edge) and V follows |d-a| ("up"). Textures then tile at world - // scale across every face — a 0.4m vent face uses 0.4 UV units, not - // a fixed 0..1 — so a brick / metal / shingle preset reads at a - // consistent density on the body, hood, and louvers. - const abx = b[0]! - a[0]! - const aby = b[1]! - a[1]! - const abz = b[2]! - a[2]! - const adx = d[0]! - a[0]! - const ady = d[1]! - a[1]! - const adz = d[2]! - a[2]! - const u = Math.sqrt(abx * abx + aby * aby + abz * abz) - const v = Math.sqrt(adx * adx + ady * ady + adz * adz) + const quadUvs = surfaceQuadUvs(a, b, c, d, [nx, ny, nz], uOffset) // Winding is (a, c, b) + (a, d, c) so the triangle face direction // matches the stored normal (see earlier note on the dark-shading // regression this fixed). positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...quadUvs.a, ...quadUvs.c, ...quadUvs.b) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...quadUvs.a, ...quadUvs.d, ...quadUvs.c) +} + +function surfaceQuadUvs( + a: number[], + b: number[], + c: number[], + d: number[], + normal: number[], + uOffset = 0, + vOffset = 0, +): Record<'a' | 'b' | 'c' | 'd', [number, number]> { + const ux = b[0]! - a[0]! + const uy = b[1]! - a[1]! + const uz = b[2]! - a[2]! + const uLength = Math.hypot(ux, uy, uz) || 1 + const unitU = [ux / uLength, uy / uLength, uz / uLength] + const unitV = [ + normal[1]! * unitU[2]! - normal[2]! * unitU[1]!, + normal[2]! * unitU[0]! - normal[0]! * unitU[2]!, + normal[0]! * unitU[1]! - normal[1]! * unitU[0]!, + ] + const project = (point: number[]): [number, number] => { + const x = point[0]! - a[0]! + const y = point[1]! - a[1]! + const z = point[2]! - a[2]! + return [ + uOffset + x * unitU[0]! + y * unitU[1]! + z * unitU[2]!, + vOffset + x * unitV[0]! + y * unitV[1]! + z * unitV[2]!, + ] + } + return { a: project(a), b: project(b), c: project(c), d: project(d) } } // pushTri: single-triangle counterpart to pushQuad. Caller orders (a, b, c) @@ -598,24 +645,24 @@ function pushTri( b: number[], c: number[], n: number[], + projection: 'surface' | 'xz' = 'surface', ) { const nLen = Math.sqrt(n[0]! * n[0]! + n[1]! * n[1]! + n[2]! * n[2]!) || 1 const nx = n[0]! / nLen const ny = n[1]! / nLen const nz = n[2]! / nLen - const abx = b[0]! - a[0]! - const aby = b[1]! - a[1]! - const abz = b[2]! - a[2]! - const acx = c[0]! - a[0]! - const acy = c[1]! - a[1]! - const acz = c[2]! - a[2]! - const u = Math.sqrt(abx * abx + aby * aby + abz * abz) - const v = Math.sqrt(acx * acx + acy * acy + acz * acz) + const uv = (point: number[]): [number, number] => { + if (projection === 'xz') return [point[0]!, point[2]!] + const mapped = surfaceQuadUvs(a, b, c, c, [nx, ny, nz]) + if (point === a) return mapped.a + if (point === b) return mapped.b + return mapped.c + } positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz) - uvs.push(0, 0, u, 0, 0, v) + uvs.push(...uv(a), ...uv(b), ...uv(c)) } /** diff --git a/packages/nodes/src/box-vent/paint.ts b/packages/nodes/src/box-vent/paint.ts new file mode 100644 index 0000000000..00e7cc751f --- /dev/null +++ b/packages/nodes/src/box-vent/paint.ts @@ -0,0 +1,41 @@ +import type { AnyNode, BoxVentMaterialRole, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' +import { BOX_VENT_MATERIAL_INDEX } from './geometry' + +type LegacyBoxVent = AnyNode & { material?: MaterialSchema; materialPreset?: string } + +export function resolveBoxVentMaterialRole(materialIndex: number | null): BoxVentMaterialRole { + return materialIndex === BOX_VENT_MATERIAL_INDEX.top ? 'top' : 'base' +} + +export const boxVentPaint = createSlotPaintCapability({ + materialTarget: 'box-vent', + resolveRole: ({ materialIndex }) => resolveBoxVentMaterialRole(materialIndex), + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return null + const materialIndex = BOX_VENT_MATERIAL_INDEX[role as BoxVentMaterialRole] + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== 'box-vent-surface' || !Array.isArray(mesh.material)) return + const previous = [...mesh.material] + if (!previous[materialIndex]) return + const next = [...previous] + next[materialIndex] = preview + mesh.material = next + restores.push(() => { + mesh.material = previous + }) + }) + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } + }, + legacyEffective: (node) => { + const legacy = node as LegacyBoxVent + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index f72d71a24a..83e078df5b 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -56,6 +57,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) // Merge live overrides (panel slider drags) on top of the store node. // Sliders write here on every `onChange` and only flush to the scene @@ -108,21 +110,36 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) - // Paint surface: explicit material wins, then preset, then the cached - // default. FrontSide everywhere — DoubleSide on the role material's + // Paint surfaces: the lower base and upper cover resolve independently. + // FrontSide everywhere — DoubleSide on the role material's // NodeMaterial poisons the MRT scene pass (see `materials.ts` line 77 / // glazing fix 9400f1c5). Earlier this path forced DoubleSide so back // faces of the vent body / hood wouldn't drop out when looking up at the // eaves; that's now a known visual tradeoff — a closed-solid extrude in // `geometry.ts` is the right fix if undersides become noticeable. const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + if (!textures) return [roleDefault, roleDefault] + const resolve = (role: 'base' | 'top') => { + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return [resolve('base'), resolve('top')] + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, + ]) // Compose slope tilt + yaw onto a single quaternion so the registered // ref's local frame is vent-mesh-local. `NodeArrowHandles` reads this diff --git a/packages/nodes/src/cupola/__tests__/geometry.test.ts b/packages/nodes/src/cupola/__tests__/geometry.test.ts index ffcf1ae978..fe92e9bb00 100644 --- a/packages/nodes/src/cupola/__tests__/geometry.test.ts +++ b/packages/nodes/src/cupola/__tests__/geometry.test.ts @@ -17,6 +17,9 @@ describe('buildCupolaGeometry', () => { expect(p.count).toBeGreaterThan(0) expect(geo.getAttribute('normal').count).toBe(p.count) expect(geo.getAttribute('uv').count).toBe(p.count) + expect(geo.getAttribute('uv2').count).toBe(p.count) + expect(new Set(geo.groups.map((group) => group.materialIndex))).toEqual(new Set([0, 1, 2])) + expect(geo.groups.reduce((count, group) => count + group.count, 0)).toBe(p.count) }) test('both roof styles build finite geometry', () => { @@ -27,6 +30,16 @@ describe('buildCupolaGeometry', () => { } }) + test('unwraps the dome perimeter continuously at metre scale', () => { + const geo = buildCupolaGeometry( + CupolaNode.parse({ width: 2, depth: 2, height: 2, roofStyle: 'dome' }), + ) + const uv = geo.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(6) + }) + test('finial adds vertices', () => { const withFinial = buildCupolaGeometry(CupolaNode.parse({ finial: true })).getAttribute( 'position', diff --git a/packages/nodes/src/cupola/__tests__/paint.test.ts b/packages/nodes/src/cupola/__tests__/paint.test.ts new file mode 100644 index 0000000000..db0de50e01 --- /dev/null +++ b/packages/nodes/src/cupola/__tests__/paint.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' +import { cupolaPaint, resolveCupolaMaterialRole } from '../paint' +import { CupolaNode } from '../schema' + +describe('cupola paint', () => { + test('maps geometry groups to base, body, and roof', () => { + expect(resolveCupolaMaterialRole(0)).toBe('base') + expect(resolveCupolaMaterialRole(1)).toBe('body') + expect(resolveCupolaMaterialRole(2)).toBe('roof') + }) + + test('updates only the selected construction part', () => { + const node = CupolaNode.parse({ slots: { body: 'library:louver' } }) + expect( + cupolaPaint.buildPatch({ + node, + role: 'roof', + material: undefined, + materialPreset: 'library:copper', + }), + ).toEqual({ + slots: { body: 'library:louver', roof: 'library:copper' }, + }) + }) + + test('uses the legacy material only for roles without an override', () => { + const node = CupolaNode.parse({ slots: { body: 'library:louver' } }) + expect( + cupolaPaint.getEffectiveMaterial?.({ node, role: 'body', nodes: {} })?.materialPreset, + ).toBe('library:louver') + expect( + cupolaPaint.getEffectiveMaterial?.({ node, role: 'base', nodes: {} })?.materialPreset, + ).toBe('preset-white') + }) +}) diff --git a/packages/nodes/src/cupola/definition.ts b/packages/nodes/src/cupola/definition.ts index cf72149a5b..3eb88078aa 100644 --- a/packages/nodes/src/cupola/definition.ts +++ b/packages/nodes/src/cupola/definition.ts @@ -4,8 +4,8 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildCupolaFloorplan } from './floorplan' +import { cupolaPaint } from './paint' import { cupolaParametrics } from './parametrics' import { CupolaNode } from './schema' @@ -108,7 +108,7 @@ const cupolaHandles: HandleDescriptor[] = [ */ export const cupolaDefinition: NodeDefinition = { kind: 'cupola', - schemaVersion: 1, + schemaVersion: 3, schema: CupolaNode, category: 'structure', surfaceRole: 'roof', @@ -120,11 +120,15 @@ export const cupolaDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'base', label: 'Base', default: 'library:preset-softwhite' }, + { slotId: 'body', label: 'Body', default: 'library:preset-softwhite' }, + { slotId: 'roof', label: 'Roof', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: cupolaPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // slope — no `buildCut`, just the dirty cascade so the parent roof's // merged shell rebuilds when the cupola moves / resizes. diff --git a/packages/nodes/src/cupola/geometry.ts b/packages/nodes/src/cupola/geometry.ts index f7535d4ec3..0359b3663c 100644 --- a/packages/nodes/src/cupola/geometry.ts +++ b/packages/nodes/src/cupola/geometry.ts @@ -1,5 +1,17 @@ import type { CupolaNode } from '@pascal-app/core' import * as THREE from 'three' +import { + copyUvToSecondaryChannel, + cumulativeProfileDistances, + type MetricUv, + planarMetricUvs, +} from '../shared/primitive-uv' + +export const CUPOLA_MATERIAL_INDEX = { + base: 0, + body: 1, + roof: 2, +} as const /** * Pure builder for the cupola mesh — a small louvered roof lantern: @@ -47,13 +59,17 @@ export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { // Base plinth (slightly wider than the body) — closed box. addBox(p, n, uv, hw + baseOvh, hd + baseOvh, 0, baseTop) + const baseEnd = p.length / 3 // Body — closed box; the louvers are applied as relief on its walls. addBox(p, n, uv, hw, hd, baseTop, bodyTop) + const bodyEnd = p.length / 3 // Cornice — overhanging slab the roof sits on. addBox(p, n, uv, hw + cornOvh, hd + cornOvh, bodyTop, corniceTop) + const corniceEnd = p.length / 3 // Louvered slats on all four body faces. addLouvers(p, n, uv, hw, hd, baseTop, bodyTop) + const louversEnd = p.length / 3 // Roof. const rhw = hw + cornOvh @@ -77,6 +93,12 @@ export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { geo.setAttribute('position', new THREE.Float32BufferAttribute(p, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(n, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)) + geo.addGroup(0, baseEnd, CUPOLA_MATERIAL_INDEX.base) + geo.addGroup(baseEnd, bodyEnd - baseEnd, CUPOLA_MATERIAL_INDEX.body) + geo.addGroup(bodyEnd, corniceEnd - bodyEnd, CUPOLA_MATERIAL_INDEX.roof) + geo.addGroup(corniceEnd, louversEnd - corniceEnd, CUPOLA_MATERIAL_INDEX.body) + geo.addGroup(louversEnd, p.length / 3 - louversEnd, CUPOLA_MATERIAL_INDEX.roof) + copyUvToSecondaryChannel(geo) geo.computeBoundingSphere() return geo } @@ -191,18 +213,36 @@ function addDomeRoof( const lng = 20 const lat = 6 let prev = ringAt(rx, rz, y0, lng) + let prevU = cumulativeProfileDistances(prev) + let domeV = 0 for (let i = 1; i <= lat; i++) { const phi = (Math.PI / 2) * (i / lat) const rf = Math.cos(phi) const y = y0 + domeH * Math.sin(phi) const ring = ringAt(rx * rf, rz * rf, y, lng) - addBand(p, n, uv, prev, ring, lng, (a, _b, c) => { - const x = (a[0]! + c[0]!) / 2 - const yy = (a[1]! + c[1]!) / 2 - y0 - const z = (a[2]! + c[2]!) / 2 - return [x, yy, z] - }) + const ringU = cumulativeProfileDistances(ring) + const nextV = domeV + averageProfileDistance(prev, ring) + addBand( + p, + n, + uv, + prev, + ring, + lng, + (a, _b, c) => { + const x = (a[0]! + c[0]!) / 2 + const yy = (a[1]! + c[1]!) / 2 - y0 + const z = (a[2]! + c[2]!) / 2 + return [x, yy, z] + }, + prevU, + ringU, + domeV, + nextV, + ) prev = ring + prevU = ringU + domeV = nextV } } @@ -219,29 +259,60 @@ function addCylinder( const lng = 12 const bottom = ringAt(r, r, y0, lng) const top = ringAt(r, r, y1, lng) - addBand(p, n, uv, bottom, top, lng, (a, _b, c) => { - const x = (a[0]! + c[0]!) / 2 - const z = (a[2]! + c[2]!) / 2 - return [x, 0, z] - }) + const ringU = cumulativeProfileDistances(bottom) + addBand( + p, + n, + uv, + bottom, + top, + lng, + (a, _b, c) => { + const x = (a[0]! + c[0]!) / 2 + const z = (a[2]! + c[2]!) / 2 + return [x, 0, z] + }, + ringU, + ringU, + y0, + y1, + ) } function addSphere(p: number[], n: number[], uv: number[], r: number, cy: number): void { const lng = 14 const lat = 8 let prev = ringAt(0, 0, cy - r, lng) + let prevU = cumulativeProfileDistances(prev) + let sphereV = 0 for (let i = 1; i <= lat; i++) { const theta = Math.PI * (i / lat) - Math.PI / 2 const ry = r * Math.sin(theta) const rr = r * Math.cos(theta) const ring = ringAt(rr, rr, cy + ry, lng) - addBand(p, n, uv, prev, ring, lng, (a, _b, c) => { - const x = (a[0]! + c[0]!) / 2 - const yy = (a[1]! + c[1]!) / 2 - cy - const z = (a[2]! + c[2]!) / 2 - return [x, yy, z] - }) + const ringU = cumulativeProfileDistances(ring) + const nextV = sphereV + averageProfileDistance(prev, ring) + addBand( + p, + n, + uv, + prev, + ring, + lng, + (a, _b, c) => { + const x = (a[0]! + c[0]!) / 2 + const yy = (a[1]! + c[1]!) / 2 - cy + const z = (a[2]! + c[2]!) / 2 + return [x, yy, z] + }, + prevU, + ringU, + sphereV, + nextV, + ) prev = ring + prevU = ringU + sphereV = nextV } } @@ -264,14 +335,35 @@ function addBand( rB: number[][], lng: number, hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[], + uA = cumulativeProfileDistances(rA), + uB = cumulativeProfileDistances(rB), + vA = 0, + vB = averageProfileDistance(rA, rB), ): void { for (let j = 0; j < lng; j++) { const a = rA[j]! const b = rA[j + 1]! const c = rB[j + 1]! const d = rB[j]! - pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d)) + pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d), [ + [uA[j]!, vA], + [uA[j + 1]!, vA], + [uB[j + 1]!, vB], + [uB[j]!, vB], + ]) + } +} + +function averageProfileDistance(a: number[][], b: number[][]): number { + let total = 0 + for (let index = 0; index < a.length; index += 1) { + total += Math.hypot( + b[index]![0]! - a[index]![0]!, + b[index]![1]! - a[index]![1]!, + b[index]![2]! - a[index]![2]!, + ) } + return total / a.length } function sub(a: number[], b: number[]): number[] { @@ -289,6 +381,7 @@ function pushQuad( c: number[], d: number[], hint: number[], + authoredUvs?: readonly MetricUv[], ) { let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!) let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!) @@ -304,19 +397,19 @@ function pushQuad( ny /= len nz /= len - const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) - const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c, d], [nx, ny, nz]) + const [uvA, uvB, uvC, uvD] = faceUvs as readonly [MetricUv, MetricUv, MetricUv, MetricUv] if (flip) { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, v) + uvs.push(...uvA, ...uvB, ...uvC) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, v, 0, v) + uvs.push(...uvA, ...uvC, ...uvD) } else { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...uvA, ...uvC, ...uvB) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...uvA, ...uvD, ...uvC) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -344,11 +437,16 @@ function pushTri( ny /= len nz /= len + const faceUvs = planarMetricUvs([a, b, c], [nx, ny, nz]) + const uvA = faceUvs[0]! + const uvB = faceUvs[1]! + const uvC = faceUvs[2]! if (flip) { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) + uvs.push(...uvA, ...uvC, ...uvB) } else { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) + uvs.push(...uvA, ...uvB, ...uvC) } - uvs.push(0, 0, 1, 0, 0, 1) for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } diff --git a/packages/nodes/src/cupola/paint.ts b/packages/nodes/src/cupola/paint.ts new file mode 100644 index 0000000000..ad7d41d7ef --- /dev/null +++ b/packages/nodes/src/cupola/paint.ts @@ -0,0 +1,39 @@ +import type { AnyNode, CupolaMaterialRole, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' +import { CUPOLA_MATERIAL_INDEX } from './geometry' + +type LegacyCupola = AnyNode & { material?: MaterialSchema; materialPreset?: string } + +export function resolveCupolaMaterialRole(materialIndex: number | null): CupolaMaterialRole { + if (materialIndex === CUPOLA_MATERIAL_INDEX.body) return 'body' + if (materialIndex === CUPOLA_MATERIAL_INDEX.roof) return 'roof' + return 'base' +} + +export const cupolaPaint = createSlotPaintCapability({ + materialTarget: 'cupola', + resolveRole: ({ materialIndex }) => resolveCupolaMaterialRole(materialIndex), + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return null + const materialIndex = CUPOLA_MATERIAL_INDEX[role as CupolaMaterialRole] + let restore: (() => void) | null = null + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== 'cupola-surface' || !Array.isArray(mesh.material)) return + const previous = [...mesh.material] + const next = [...previous] + next[materialIndex] = preview + mesh.material = next + restore = () => { + mesh.material = previous + } + }) + return restore + }, + legacyEffective: (node) => { + const legacy = node as LegacyCupola + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 71251ae76b..d704f68c7c 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -42,6 +43,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -68,13 +70,28 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { }, [segment, node.position[0], node.position[2]]) const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const resolve = (role: 'base' | 'body' | 'roof') => { + if (!textures) return roleDefault + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return [resolve('base'), resolve('body'), resolve('roof')] + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, + ]) const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) const composedQuat = useMemo(() => { diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 8774d2007d..4ba74f659f 100644 --- a/packages/nodes/src/dormer/__tests__/geometry.test.ts +++ b/packages/nodes/src/dormer/__tests__/geometry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { getRoofSegmentSurfaceY, type RoofSegmentNode } from '@pascal-app/core' +import { getRoofSegmentSurfaceY, type RoofSegmentNode, type RoofType } from '@pascal-app/core' import { getDormerExposedFaces } from '../csg-geometry' import { buildDormerGhostGeometry, @@ -29,6 +29,39 @@ describe('buildDormerGhostGeometry (placement preview)', () => { b.computeBoundingBox() expect(b.boundingBox!.max.y).toBeGreaterThan(a.boundingBox!.max.y) }) + + test.each([ + ['flat', 1], + ['gable', 2], + ['hip', 2], + ['shed', 2], + ['gambrel', 3], + ['mansard', 3], + ['dutch', 4], + ] satisfies [ + RoofType, + number, + ][])('builds the canonical %s height profile', (roofType, levels) => { + const wallHeight = 1 + const geo = buildDormerGhostGeometry( + DormerNode.parse({ roofType, width: 4, depth: 3, height: wallHeight, roofHeight: 1.2 }), + ) + const position = geo.getAttribute('position') + const roofLevels = new Set() + for (let index = 0; index < position.count; index++) { + const y = position.getY(index) + if (y >= wallHeight - 0.001) roofLevels.add(Math.round(y * 1000)) + } + + expect(roofLevels.size).toBe(levels) + }) + + test('assigns roof faces to the roof material slot', () => { + const geo = buildDormerGhostGeometry(DormerNode.parse({ roofType: 'mansard' })) + + expect(geo.groups.some((group) => group.materialIndex === 0)).toBe(true) + expect(geo.groups.some((group) => group.materialIndex === 3)).toBe(true) + }) }) describe('windowShape predicates', () => { diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index f4d6c6c898..8b5d786119 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -20,7 +20,7 @@ import { SUBTRACTION, } from '@pascal-app/viewer' import * as THREE from 'three' -import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { buildDormerShellGeometry } from './geometry' // Legacy default for the hung-wall (skirt) height. Used as a fallback // when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes). @@ -41,57 +41,11 @@ const _scale = new THREE.Vector3(1, 1, 1) * the live preview during slider drags so we don't re-run CSG on every * pointer move. Also used by the placement / move-tool ghost. * - * Builds a rectangular body + simple roof in dormer-mesh-local. For - * `flat` dormers the roof triangle is skipped. Other roof types use - * the gable approximation — it's a rough silhouette by design. - * * The wall sits at material slot 0 and the roof at slot 3 so it picks * up the same material array the renderer passes for the CSG output. */ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeometry { - const w = Math.max(0.05, dormer.width) - const d = Math.max(0.05, dormer.depth) - const wallH = Math.max(0.05, dormer.height) - const roofH = Math.max(0, dormer.roofHeight) - const skirt = dormerSkirtHeight(dormer) - const isFlat = dormer.roofType === 'flat' || roofH === 0 - - // Body box: foot at y = -skirt, top at y = wallH. - // BoxGeometry is indexed; ExtrudeGeometry below is not. mergeGeometries - // refuses mixed input ("index attribute exists among all geometries, - // or in none of them") — drop the body's index so both inputs match. - const indexedBody = new THREE.BoxGeometry(w, wallH + skirt, d) - indexedBody.translate(0, (wallH - skirt) / 2, 0) - const body = indexedBody.toNonIndexed() - indexedBody.dispose() - const bVtx = body.getAttribute('position').count - body.clearGroups() - body.addGroup(0, bVtx, 0) - - if (isFlat) { - if (!body.getAttribute('normal')) body.computeVertexNormals() - return body - } - - // Roof: extruded triangle from eave (y = wallH) to peak (y = wallH + roofH). - // Apex points along +Y, base spans the width. Extrude along Z (depth). - const roofShape = new THREE.Shape() - roofShape.moveTo(-w / 2, 0) - roofShape.lineTo(w / 2, 0) - roofShape.lineTo(0, roofH) - roofShape.lineTo(-w / 2, 0) - const roof = new THREE.ExtrudeGeometry(roofShape, { depth: d, bevelEnabled: false }) - roof.translate(0, wallH, -d / 2) - - const rVtx = roof.getAttribute('position').count - roof.clearGroups() - roof.addGroup(0, rVtx, 3) - - const merged = mergeGeometries([body, roof], true) ?? body - body.dispose() - roof.dispose() - if (!merged.getAttribute('normal')) merged.computeVertexNormals() - return merged + return buildDormerShellGeometry(dormer) } export function createDormerArchShape(w: number, h: number, archHeight: number): THREE.Shape { diff --git a/packages/nodes/src/dormer/geometry.ts b/packages/nodes/src/dormer/geometry.ts index 10294f6b80..a13b290536 100644 --- a/packages/nodes/src/dormer/geometry.ts +++ b/packages/nodes/src/dormer/geometry.ts @@ -1,4 +1,10 @@ -import type { DormerNode } from '@pascal-app/core' +import { + type DormerNode, + getPitchFromActiveRoofHeight, + getRoofModuleFaces, + getRoofShapeRatios, + ROOF_SHAPE_DEFAULTS, +} from '@pascal-app/core' import * as THREE from 'three' /** @@ -17,44 +23,70 @@ export const DORMER_PLACEMENT_SNAP_M = 0.05 export const DORMER_PLACEMENT_ROTATION_STEP = (15 * Math.PI) / 180 /** - * Lightweight silhouette geometry used by the placement / move-tool - * ghost preview only. Renders the dormer as an extruded pentagon - * (rectangle body + triangular gable) dropped by `wallSkirtHeight` below - * the anchor so the cursor sits at the floor of the dormer the way the - * committed CSG geometry does. - * - * For `roofType === 'flat'` (or `roofHeight === 0`) the gable apex is - * skipped and the shape collapses to a rectangle. Other roof types use - * the gable approximation — exact per-type silhouettes are a future - * improvement. - * - * Kept self-contained (no `@pascal-app/viewer` imports) so the geometry - * test doesn't drag in the CSG / BVH module graph, which fails to load - * outside of a browser/WebGL context. The viewer has its own - * `buildDormerFallbackGeometry` that mirrors this shape — used both as - * the CSG fallback when boolean ops fail and as the live-drag preview - * in the dormer renderer. + * Builds the lightweight placement and live-edit shell from the same + * per-type face generator used by committed roof geometry. */ -export function buildDormerGhostGeometry(node: DormerNode): THREE.BufferGeometry { +export function buildDormerShellGeometry(node: DormerNode): THREE.BufferGeometry { const w = Math.max(0.05, node.width) const wallH = Math.max(0.05, node.height) const roofH = Math.max(0, node.roofHeight) const d = Math.max(0.05, node.depth) - const skirt = Math.max(0.05, node.wallSkirtHeight) - const hw = w / 2 - const isFlat = node.roofType === 'flat' || roofH === 0 + const skirt = Math.max(0.05, node.wallSkirtHeight ?? 2) + const isShed = node.roofType === 'shed' + const segW = isShed ? w : d + const segD = isShed ? d : w + const pitch = getPitchFromActiveRoofHeight({ + roofType: node.roofType, + width: segW, + depth: segD, + roofHeight: roofH, + }) + const faces = getRoofModuleFaces({ + type: node.roofType, + w: segW, + d: segD, + wh: wallH, + rh: roofH, + baseY: -skirt, + insets: {}, + baseW: segW, + baseD: segD, + tanTheta: Math.tan((pitch * Math.PI) / 180), + shapeRatios: getRoofShapeRatios(ROOF_SHAPE_DEFAULTS), + }) + const positions: number[] = [] + const materialGroups: Array<{ start: number; count: number; materialIndex: number }> = [] + + for (const face of faces) { + if (face.length < 3) continue + const a = new THREE.Vector3(face[0]!.x, face[0]!.y, face[0]!.z) + const b = new THREE.Vector3(face[1]!.x, face[1]!.y, face[1]!.z) + const c = new THREE.Vector3(face[2]!.x, face[2]!.y, face[2]!.z) + const normal = b.clone().sub(a).cross(c.clone().sub(a)).normalize() + const start = positions.length / 3 + for (let index = 1; index < face.length - 1; index++) { + for (const point of [face[0]!, face[index]!, face[index + 1]!]) { + positions.push(point.x, point.y, point.z) + } + } + materialGroups.push({ + start, + count: positions.length / 3 - start, + materialIndex: normal.y > 0.01 ? 3 : 0, + }) + } - const shape = new THREE.Shape() - shape.moveTo(-hw, -skirt) - shape.lineTo(hw, -skirt) - shape.lineTo(hw, wallH) - if (!isFlat) shape.lineTo(0, wallH + roofH) - shape.lineTo(-hw, wallH) - shape.closePath() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + for (const group of materialGroups) + geometry.addGroup(group.start, group.count, group.materialIndex) + if (!isShed) geometry.rotateY(Math.PI / 2) + geometry.computeVertexNormals() + return geometry +} - const geo = new THREE.ExtrudeGeometry(shape, { depth: d, bevelEnabled: false }) - geo.translate(0, 0, -d / 2) - return geo +export function buildDormerGhostGeometry(node: DormerNode): THREE.BufferGeometry { + return buildDormerShellGeometry(node) } /** diff --git a/packages/nodes/src/downspout/definition.test.ts b/packages/nodes/src/downspout/definition.test.ts new file mode 100644 index 0000000000..7ea355a9f0 --- /dev/null +++ b/packages/nodes/src/downspout/definition.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { DownspoutNode } from '@pascal-app/core' +import { downspoutDefinition } from './definition' + +describe('downspout paint capability', () => { + test('paints the complete downspout as one surface', () => { + const node = DownspoutNode.parse({ id: 'downspout_test', type: 'downspout' }) + const paint = downspoutDefinition.capabilities.paint + + expect(paint?.materialTarget).toBe('downspout') + expect(paint?.resolveRole({ node, materialIndex: null })).toBe('surface') + expect( + paint?.buildPatch({ + node, + role: 'surface', + material: undefined, + materialPreset: 'library:metal-steel', + }), + ).toEqual({ + slots: { surface: 'library:metal-steel' }, + }) + }) +}) diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index 0af06ccdd4..ff487c54dc 100644 --- a/packages/nodes/src/downspout/definition.ts +++ b/packages/nodes/src/downspout/definition.ts @@ -9,6 +9,7 @@ import { useLiveNodeOverrides, useScene, } from '@pascal-app/core' +import { surfacePaintCapability } from '../shared/surface-paint' import { downspoutParametrics } from './parametrics' import { computeDownspoutPath, @@ -160,7 +161,7 @@ const downspoutHandles: HandleDescriptor[] = [ */ export const downspoutDefinition: NodeDefinition = { kind: 'downspout', - schemaVersion: 2, + schemaVersion: 3, schema: DownspoutNode, category: 'structure', surfaceRole: 'roof', @@ -176,9 +177,11 @@ export const downspoutDefinition: NodeDefinition = { }, capabilities: { + slots: () => [{ slotId: 'surface', label: 'Surface', default: 'library:preset-softwhite' }], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: { ...surfacePaintCapability, materialTarget: 'downspout' }, // Logically a roof accessory — registers under the segment, has // no buildCut, just the standard dirty cascade. roofAccessory: {}, diff --git a/packages/nodes/src/downspout/geometry.test.ts b/packages/nodes/src/downspout/geometry.test.ts new file mode 100644 index 0000000000..94e385c249 --- /dev/null +++ b/packages/nodes/src/downspout/geometry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { DownspoutNode } from '@pascal-app/core' +import { buildDownspoutGeometry } from './geometry' + +describe('downspout geometry', () => { + test('preserves metre scale along a straight run', () => { + const geometry = buildDownspoutGeometry( + DownspoutNode.parse({ + id: 'downspout_uv', + type: 'downspout', + length: 3, + shape: 'rect', + strapStyle: 'none', + terminal: 'straight', + }), + ) + const uv = geometry.getAttribute('uv') + expect(geometry.getAttribute('uv2').count).toBe(uv.count) + const values = Array.from({ length: uv.count }, (_, index) => [ + uv.getX(index), + uv.getY(index), + ]).flat() + + expect(Math.max(...values) - Math.min(...values)).toBeGreaterThanOrEqual(2.9) + }) +}) diff --git a/packages/nodes/src/downspout/geometry.ts b/packages/nodes/src/downspout/geometry.ts index aa5f563296..f4a85e057b 100644 --- a/packages/nodes/src/downspout/geometry.ts +++ b/packages/nodes/src/downspout/geometry.ts @@ -2,6 +2,12 @@ import type { DownspoutNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import type { OutletDims } from '../gutter/profile-geometry' +import { + applyCylinderWorldUvs, + applyPlanarWorldUvs, + applySphereWorldUvs, + copyUvToSecondaryChannel, +} from '../shared/primitive-uv' import { computeDownspoutPath, type DownspoutPath, @@ -97,6 +103,7 @@ export function buildDownspoutGeometry( for (const p of pieces) p.dispose() } merged.computeVertexNormals() + copyUvToSecondaryChannel(merged) return merged } @@ -117,6 +124,8 @@ function segmentBetween( dims.shape === 'round' ? new THREE.CylinderGeometry(dims.halfX, dims.halfX, len, RADIAL_SEGMENTS).toNonIndexed() : new THREE.BoxGeometry(2 * dims.halfX, len, 2 * dims.halfZ).toNonIndexed() + if (dims.shape === 'round') applyCylinderWorldUvs(geo, dims.halfX, len) + else applyPlanarWorldUvs(geo) // The primitive runs along +Y centred at origin; rotate +Y onto the // segment direction, then drop it on the midpoint. geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, dir.normalize())) @@ -181,6 +190,7 @@ function jointAt( ): THREE.BufferGeometry { if (dims.shape === 'round') { const geo = new THREE.SphereGeometry(dims.halfX, JOINT_SEGMENTS, JOINT_SEGMENTS).toNonIndexed() + applySphereWorldUvs(geo, dims.halfX) geo.translate(p.x, p.y, p.z) return geo } @@ -190,6 +200,7 @@ function jointAt( if (bis.lengthSq() < 1e-8) bis.copy(dirOut) // straight-through; degenerate bis.normalize() const geo = new THREE.BoxGeometry(2 * dims.halfX, 2 * dims.halfZ, 2 * dims.halfZ).toNonIndexed() + applyPlanarWorldUvs(geo) geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, bis)) geo.translate(p.x, p.y, p.z) return geo @@ -221,6 +232,7 @@ function buildStraps( for (let i = 0; i < count; i++) { const y = count > 1 ? top - STRAP_END_MARGIN - i * stride : (top + bottom) / 2 const band = new THREE.BoxGeometry(w, STRAP_THICKNESS, d).toNonIndexed() + applyPlanarWorldUvs(band) band.translate(0, y, z) straps.push(band) } @@ -234,6 +246,7 @@ function buildStraps( function buildSplash(path: DownspoutPath): THREE.BufferGeometry | null { const [bx, by, bz] = path.bottom const slab = new THREE.BoxGeometry(SPLASH_WIDTH, SPLASH_THICKNESS, SPLASH_LENGTH).toNonIndexed() + applyPlanarWorldUvs(slab) // Tilt the far (+Z) end down so it slopes away from the wall. slab.rotateX(SPLASH_TILT) slab.translate(bx, by - SPLASH_THICKNESS / 2, bz + SPLASH_LENGTH / 2) diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index 9eebee1fd6..f16bb2040a 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -14,6 +14,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -55,6 +56,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -135,13 +137,27 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { useEffect(() => () => geometry.dispose(), [geometry]) const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { + if (!textures) { + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + } + const slotMaterial = resolveMaterialRef(node.slots?.surface, sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (!node.material && !node.materialPreset) { return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots?.surface, + node.material, + node.materialPreset, + sceneMaterials, + ]) // Map downspout-local geometry into the host segment's local frame (where the // trim cut prisms live). Recompose the same outlet pose the inner mesh group diff --git a/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts b/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts index 59d61e3184..fe7cc3c152 100644 --- a/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts @@ -17,6 +17,9 @@ describe('buildEyebrowVentGeometry', () => { expect(p.count).toBeGreaterThan(0) expect(geo.getAttribute('normal').count).toBe(p.count) expect(geo.getAttribute('uv').count).toBe(p.count) + expect(geo.getAttribute('uv2').count).toBe(p.count) + expect(new Set(geo.groups.map((group) => group.materialIndex))).toEqual(new Set([0, 1])) + expect(geo.groups.reduce((count, group) => count + group.count, 0)).toBe(p.count) expect(allFinite(geo)).toBe(true) }) @@ -25,9 +28,22 @@ describe('buildEyebrowVentGeometry', () => { const geo = buildEyebrowVentGeometry(EyebrowVentNode.parse({ style })) expect(geo.getAttribute('position').count).toBeGreaterThan(0) expect(allFinite(geo)).toBe(true) + if (style === 'slant-box') { + expect(new Set(geo.groups.map((group) => group.materialIndex))).toEqual(new Set([0, 1])) + } } }) + test('unwraps the curved hood continuously at metre scale', () => { + const geo = buildEyebrowVentGeometry( + EyebrowVentNode.parse({ width: 2, depth: 3, height: 1, style: 'half-round' }), + ) + const uv = geo.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(3) + }) + test('louvers add vertices', () => { const withLouvers = buildEyebrowVentGeometry( EyebrowVentNode.parse({ louverCount: 4 }), diff --git a/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts b/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts new file mode 100644 index 0000000000..7d5060a953 --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test' +import { eyebrowVentPaint, resolveEyebrowVentMaterialRole } from '../paint' +import { EyebrowVentNode } from '../schema' + +describe('eyebrow vent paint', () => { + test('maps geometry groups to hood and front', () => { + expect(resolveEyebrowVentMaterialRole(0)).toBe('hood') + expect(resolveEyebrowVentMaterialRole(1)).toBe('front') + }) + + test('updates only the selected construction part', () => { + const node = EyebrowVentNode.parse({ slots: { hood: 'library:metal' } }) + expect( + eyebrowVentPaint.buildPatch({ + node, + role: 'front', + material: undefined, + materialPreset: 'library:louver', + }), + ).toEqual({ + slots: { hood: 'library:metal', front: 'library:louver' }, + }) + }) + + test('uses the legacy material only for roles without an override', () => { + const node = EyebrowVentNode.parse({ slots: { hood: 'library:metal' } }) + expect( + eyebrowVentPaint.getEffectiveMaterial?.({ node, role: 'hood', nodes: {} })?.materialPreset, + ).toBe('library:metal') + expect( + eyebrowVentPaint.getEffectiveMaterial?.({ node, role: 'front', nodes: {} })?.materialPreset, + ).toBe('preset-white') + }) +}) diff --git a/packages/nodes/src/eyebrow-vent/definition.ts b/packages/nodes/src/eyebrow-vent/definition.ts index 2bd1e97eda..7496808933 100644 --- a/packages/nodes/src/eyebrow-vent/definition.ts +++ b/packages/nodes/src/eyebrow-vent/definition.ts @@ -4,8 +4,8 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildEyebrowVentFloorplan } from './floorplan' +import { eyebrowVentPaint } from './paint' import { eyebrowVentParametrics } from './parametrics' import { EyebrowVentNode } from './schema' @@ -112,7 +112,7 @@ const eyebrowVentHandles: HandleDescriptor[] = [ */ export const eyebrowVentDefinition: NodeDefinition = { kind: 'eyebrow-vent', - schemaVersion: 1, + schemaVersion: 3, schema: EyebrowVentNode, category: 'structure', surfaceRole: 'roof', @@ -127,11 +127,14 @@ export const eyebrowVentDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'hood', label: 'Hood', default: 'library:preset-softwhite' }, + { slotId: 'front', label: 'Front', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: eyebrowVentPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the slope — // no `buildCut`, just the dirty cascade so the parent roof's merged shell // rebuilds when the vent moves / resizes. diff --git a/packages/nodes/src/eyebrow-vent/geometry.ts b/packages/nodes/src/eyebrow-vent/geometry.ts index 72462046db..a3f8112f99 100644 --- a/packages/nodes/src/eyebrow-vent/geometry.ts +++ b/packages/nodes/src/eyebrow-vent/geometry.ts @@ -1,5 +1,16 @@ import type { EyebrowVentNode } from '@pascal-app/core' import * as THREE from 'three' +import { + copyUvToSecondaryChannel, + cumulativeProfileDistances, + type MetricUv, + planarMetricUvs, +} from '../shared/primitive-uv' + +export const EYEBROW_VENT_MATERIAL_INDEX = { + hood: 0, + front: 1, +} as const /** * Pure builder for the eyebrow-vent mesh. Three styles, all seated directly on @@ -35,12 +46,13 @@ export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeo const uv: number[] = [] // The hood seats directly on the roof at y=0 — no flashing plate. + let frontStart: number if (node.style === 'half-round') { - addHalfRound(p, n, uv, w, d, h, 0, slats) + frontStart = addHalfRound(p, n, uv, w, d, h, 0, slats) } else if (node.style === 'slant-box') { - addSlantBox(p, n, uv, w, d, h, 0, slats, backRatio) + frontStart = addSlantBox(p, n, uv, w, d, h, 0, slats, backRatio) } else { - addScoop(p, n, uv, w, d, h, 0, slats) + frontStart = addScoop(p, n, uv, w, d, h, 0, slats) } // Double-side the whole mesh at the geometry level: append a back-facing @@ -50,12 +62,22 @@ export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeo // material, which poisons the MRT scene pass (see the ridge-vent renderer // note). Only one of each coplanar pair front-faces any camera, so there's // no z-fighting. + const frontEnd = p.length / 3 doubleSide(p, n, uv) const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(p, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(n, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)) + geo.addGroup(0, frontStart, EYEBROW_VENT_MATERIAL_INDEX.hood) + if (frontEnd > frontStart) { + geo.addGroup(frontStart, frontEnd - frontStart, EYEBROW_VENT_MATERIAL_INDEX.front) + } + geo.addGroup(frontEnd, frontStart, EYEBROW_VENT_MATERIAL_INDEX.hood) + if (frontEnd > frontStart) { + geo.addGroup(frontEnd + frontStart, frontEnd - frontStart, EYEBROW_VENT_MATERIAL_INDEX.front) + } + copyUvToSecondaryChannel(geo) geo.computeBoundingSphere() return geo } @@ -71,7 +93,7 @@ function addScoop( h: number, yB: number, slats: number, -): void { +): number { const a = w / 2 const b = h const zF = d / 2 @@ -87,16 +109,35 @@ function addScoop( const z = zF - v * d rings.push(halfRing(a, b, yB, z, scale, NF)) } + const ringUvs = rings.map((ring) => cumulativeProfileDistances(ring)) + const ringV = [0] + for (let i = 1; i <= NZ; i++) { + ringV.push(ringV[i - 1]! + averageProfileDistance(rings[i - 1]!, rings[i]!)) + } for (let i = 0; i < NZ; i++) { - addBand(p, n, uv, rings[i + 1]!, rings[i]!, NF, (qa, qb, qc, qd) => { - const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 - const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 - return [mx, my - yB, 0] // radial-out from the spine - }) + addBand( + p, + n, + uv, + rings[i + 1]!, + rings[i]!, + NF, + (qa, qb, qc, qd) => { + const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 + const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 + return [mx, my - yB, 0] // radial-out from the spine + }, + ringUvs[i + 1], + ringUvs[i], + ringV[i + 1], + ringV[i], + ) } // Horizontal louvers filling the front half-ellipse opening. + const frontStart = p.length / 3 addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats) + return frontStart } // ─── Style: half-round (D-shaped louver vent) ───────────────────────────── @@ -110,7 +151,7 @@ function addHalfRound( h: number, yB: number, slats: number, -): void { +): number { const a = w / 2 // Cap the crown at a true half-round — never bulge past a semicircle, so the // top reads as a clean, smaller-radius arch. `height` flattens it further. @@ -121,13 +162,26 @@ function addHalfRound( const ringF = halfRing(a, b, yB, zF, 1, NF) const ringB = halfRing(a, b, yB, zB, 1, NF) + const ringU = cumulativeProfileDistances(ringF) // Curved top shell (constant cross section). - addBand(p, n, uv, ringB, ringF, NF, (qa, qb, qc, qd) => { - const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 - const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 - return [mx, my - yB, 0] - }) + addBand( + p, + n, + uv, + ringB, + ringF, + NF, + (qa, qb, qc, qd) => { + const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 + const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 + return [mx, my - yB, 0] + }, + ringU, + ringU, + d, + 0, + ) // Back cap — fan the rear semicircle, facing -Z. const backCenter = [0, yB, zB] @@ -136,7 +190,9 @@ function addHalfRound( } // Louvered front face (a slat count bumped up — the D-vent reads denser). + const frontStart = p.length / 3 addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats > 0 ? Math.max(slats, 4) : 0) + return frontStart } // ─── Style: slant-box (low hooded box) ──────────────────────────────────── @@ -151,7 +207,7 @@ function addSlantBox( yB: number, slats: number, backRatio: number, -): void { +): number { const hw = w / 2 const zF = d / 2 const zB = -d / 2 @@ -189,6 +245,7 @@ function addSlantBox( pushQuad(p, n, uv, [oR, oB, zF], [hw, oB, zF], [hw, oT, zF], [oR, oT, zF], [0, 0, 1]) // right // Recessed screen panel at the back of the pocket (blocks see-through). + const frontStart = p.length / 3 const screenZ = zF - d * 0.2 pushQuad( p, @@ -204,6 +261,7 @@ function addSlantBox( // Horizontal louvers inside the pocket — bounded by the opening in height // and recessed in depth between the frame face and the screen. addRectLouvers(p, n, uv, oR, oB, oT, zF - d * 0.07, slats) + return frontStart } // ─── Louver helpers ─────────────────────────────────────────────────────── @@ -354,14 +412,35 @@ function addBand( rB: number[][], lng: number, hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[], + uA = cumulativeProfileDistances(rA), + uB = cumulativeProfileDistances(rB), + vA = 0, + vB = averageProfileDistance(rA, rB), ): void { for (let j = 0; j < lng; j++) { const a = rA[j]! const b = rA[j + 1]! const c = rB[j + 1]! const d = rB[j]! - pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d)) + pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d), [ + [uA[j]!, vA], + [uA[j + 1]!, vA], + [uB[j + 1]!, vB], + [uB[j]!, vB], + ]) + } +} + +function averageProfileDistance(a: number[][], b: number[][]): number { + let total = 0 + for (let index = 0; index < a.length; index += 1) { + total += Math.hypot( + b[index]![0]! - a[index]![0]!, + b[index]![1]! - a[index]![1]!, + b[index]![2]! - a[index]![2]!, + ) } + return total / a.length } // Append a reversed-winding, negated-normal copy of every triangle already in @@ -410,6 +489,7 @@ function pushQuad( c: number[], d: number[], hint: number[], + authoredUvs?: readonly MetricUv[], ) { let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!) let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!) @@ -425,19 +505,19 @@ function pushQuad( ny /= len nz /= len - const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) - const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c, d], [nx, ny, nz]) + const [uvA, uvB, uvC, uvD] = faceUvs as readonly [MetricUv, MetricUv, MetricUv, MetricUv] if (flip) { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, v) + uvs.push(...uvA, ...uvB, ...uvC) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, v, 0, v) + uvs.push(...uvA, ...uvC, ...uvD) } else { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...uvA, ...uvC, ...uvB) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...uvA, ...uvD, ...uvC) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -465,11 +545,16 @@ function pushTri( ny /= len nz /= len + const faceUvs = planarMetricUvs([a, b, c], [nx, ny, nz]) + const uvA = faceUvs[0]! + const uvB = faceUvs[1]! + const uvC = faceUvs[2]! if (flip) { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) + uvs.push(...uvA, ...uvC, ...uvB) } else { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) + uvs.push(...uvA, ...uvB, ...uvC) } - uvs.push(0, 0, 1, 0, 0, 1) for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } diff --git a/packages/nodes/src/eyebrow-vent/paint.ts b/packages/nodes/src/eyebrow-vent/paint.ts new file mode 100644 index 0000000000..d384f7378d --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/paint.ts @@ -0,0 +1,40 @@ +import type { AnyNode, EyebrowVentMaterialRole, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' +import { EYEBROW_VENT_MATERIAL_INDEX } from './geometry' + +type LegacyEyebrowVent = AnyNode & { material?: MaterialSchema; materialPreset?: string } + +export function resolveEyebrowVentMaterialRole( + materialIndex: number | null, +): EyebrowVentMaterialRole { + return materialIndex === EYEBROW_VENT_MATERIAL_INDEX.front ? 'front' : 'hood' +} + +export const eyebrowVentPaint = createSlotPaintCapability({ + materialTarget: 'eyebrow-vent', + resolveRole: ({ materialIndex }) => resolveEyebrowVentMaterialRole(materialIndex), + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return null + const materialIndex = EYEBROW_VENT_MATERIAL_INDEX[role as EyebrowVentMaterialRole] + let restore: (() => void) | null = null + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== 'eyebrow-vent-surface' || !Array.isArray(mesh.material)) + return + const previous = [...mesh.material] + const next = [...previous] + next[materialIndex] = preview + mesh.material = next + restore = () => { + mesh.material = previous + } + }) + return restore + }, + legacyEffective: (node) => { + const legacy = node as LegacyEyebrowVent + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index cf019e6c66..fe9bfe2ef4 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -42,6 +43,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -70,13 +72,28 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => }, [segment, node.position[0], node.position[2]]) const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const resolve = (role: 'hood' | 'front') => { + if (!textures) return roleDefault + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return [resolve('hood'), resolve('front')] + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, + ]) const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) const composedQuat = useMemo(() => { diff --git a/packages/nodes/src/gutter/definition.test.ts b/packages/nodes/src/gutter/definition.test.ts new file mode 100644 index 0000000000..429c04d026 --- /dev/null +++ b/packages/nodes/src/gutter/definition.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { GutterNode } from '@pascal-app/core' +import { gutterDefinition } from './definition' + +describe('gutter paint capability', () => { + test('paints the complete gutter as one surface', () => { + const node = GutterNode.parse({ id: 'gutter_test', type: 'gutter' }) + const paint = gutterDefinition.capabilities.paint + + expect(paint?.materialTarget).toBe('gutter') + expect( + paint?.resolveRole({ + node, + materialIndex: null, + }), + ).toBe('surface') + expect( + paint?.buildPatch({ + node, + role: 'surface', + material: undefined, + materialPreset: 'library:metal-steel', + }), + ).toEqual({ + slots: { surface: 'library:metal-steel' }, + }) + }) +}) diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index a1dfdb15c4..3fb4ded5b4 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -4,6 +4,7 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' +import { surfacePaintCapability } from '../shared/surface-paint' import { buildGutterFloorplan } from './floorplan' import { snapLengthToCorner } from './length-snap' import { gutterParametrics } from './parametrics' @@ -140,7 +141,7 @@ const gutterHandles: HandleDescriptor[] = [ */ export const gutterDefinition: NodeDefinition = { kind: 'gutter', - schemaVersion: 2, + schemaVersion: 3, schema: GutterNode, category: 'structure', surfaceRole: 'roof', @@ -156,9 +157,11 @@ export const gutterDefinition: NodeDefinition = { }, capabilities: { + slots: () => [{ slotId: 'surface', label: 'Surface', default: 'library:preset-softwhite' }], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: { ...surfacePaintCapability, materialTarget: 'gutter' }, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // eave fascia — no `buildCut`, just the dirty cascade so the // parent roof's merged shell rebuilds when the gutter moves / diff --git a/packages/nodes/src/gutter/eave-snap.test.ts b/packages/nodes/src/gutter/eave-snap.test.ts new file mode 100644 index 0000000000..cb3c4688e4 --- /dev/null +++ b/packages/nodes/src/gutter/eave-snap.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { resolveEaveSnap } from './eave-snap' + +describe('resolveEaveSnap', () => { + test('snaps a mansard roof to all four canonical eaves', () => { + const segment = RoofSegmentNode.parse({ roofType: 'mansard', width: 8, depth: 6 }) + + expect(resolveEaveSnap(segment, 3.5, 0).side).toBe('+X') + expect(resolveEaveSnap(segment, -3.5, 0).side).toBe('-X') + expect(resolveEaveSnap(segment, 0, 2.5).side).toBe('+Z') + expect(resolveEaveSnap(segment, 0, -2.5).side).toBe('-Z') + }) + + test('keeps gable snapping on its two eave sides', () => { + const segment = RoofSegmentNode.parse({ roofType: 'gable', width: 8, depth: 6 }) + + expect(resolveEaveSnap(segment, 3.5, 0.1).side).toBe('+Z') + expect(resolveEaveSnap(segment, -3.5, -0.1).side).toBe('-Z') + }) +}) diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts index 6638f03dd7..e79114e11e 100644 --- a/packages/nodes/src/gutter/eave-snap.ts +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -2,6 +2,7 @@ import { computeGutterEaveY, GUTTER_EAVE_TUCK_INWARD, GUTTER_EAVE_TUCK_UP, + getRoofShapeEaveSides, type RoofSegmentNode, type RoofType, } from '@pascal-app/core' @@ -70,7 +71,7 @@ export function computeEaveY( * regardless of which side the cursor is on — clicking on the high * side still rolls the gutter down to the low eave. * - * - `hip` / `flat` / `dutch`: 4-way. The slope the user is standing + * - Four-eave roofs: the slope the user is standing * on is determined by whichever of `|lx|/halfW` or `|lz|/halfD` is * larger — same `max(fx, fz)` discriminator the segment-hit's * `analyticalSurfaceY` uses for hip. Sign of the dominant axis @@ -78,11 +79,7 @@ export function computeEaveY( * lower run has all four eaves at the eave line — it gets the same * 4-way snap as hip. * - * - `gable` / `gambrel` / `mansard`: 2-way `±Z`. Mansard has real - * 4-side eaves in plan, but the segment-hit formula approximates it - * as 2-slope (depth-only), so we stay consistent here — the user - * can re-place the gutter manually on a side eave if mansard - * becomes important. + * - `gable` / `gambrel`: 2-way `±Z`. */ function pickEaveSide( roofType: RoofType, @@ -91,9 +88,10 @@ function pickEaveSide( halfW: number, halfD: number, ): EaveSide { - if (roofType === 'shed') return '+Z' + const sides = getRoofShapeEaveSides(roofType) + if (sides.length === 1) return sides[0]! - if (roofType === 'hip' || roofType === 'flat' || roofType === 'dutch') { + if (sides.includes('+X')) { const fx = halfW > 0 ? Math.abs(localX) / halfW : 0 const fz = halfD > 0 ? Math.abs(localZ) / halfD : 0 if (fx > fz) return localX < 0 ? '-X' : '+X' diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index d0722694b7..906df1631f 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -8,6 +8,11 @@ import { } from '@pascal-app/viewer' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { + applyCylinderWorldUvs, + applyPlanarWorldUvs, + copyUvToSecondaryChannel, +} from '../shared/primitive-uv' import { type GutterMitres, NO_MITRES } from './corner-mitre' import { OUTLET_STUB_LENGTH, @@ -238,10 +243,14 @@ export function buildGutterGeometry( } const cutGeometry = csgGeometry(workingBrush) merged.dispose() - return bendGutterGeometryAlongArc(cutGeometry, node, mitres) + const finished = bendGutterGeometryAlongArc(cutGeometry, node, mitres) + copyUvToSecondaryChannel(finished) + return finished } - return bendGutterGeometryAlongArc(merged, node, mitres) + const finished = bendGutterGeometryAlongArc(merged, node, mitres) + copyUvToSecondaryChannel(finished) + return finished } function gutterArcSteps(node: GutterNode, length: number): number { @@ -610,6 +619,7 @@ function buildHangers( HANGER_BAR_THICKNESS, strapDepth, ).toNonIndexed() + applyPlanarWorldUvs(bar) // Center the bar at X = position, Y just above the rim line, Z // straddling 0 so the strap covers the full back-to-front span. bar.translate(x, HANGER_BAR_THICKNESS / 2 + 0.001, rimWidth / 2) @@ -683,14 +693,18 @@ function resolveOutletPlacements( /** Cylinder (round) or box (rect) sized to `dims`, height `h` along Y. */ function outletSolid(dims: OutletDims, h: number): THREE.BufferGeometry { if (dims.shape === 'round') { - return new THREE.CylinderGeometry( + const geometry = new THREE.CylinderGeometry( dims.halfX, dims.halfX, h, OUTLET_RADIAL_SEGMENTS, ).toNonIndexed() + applyCylinderWorldUvs(geometry, dims.halfX, h) + return geometry } - return new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed() + const geometry = new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed() + applyPlanarWorldUvs(geometry) + return geometry } /** @@ -719,12 +733,14 @@ function buildOutletFunnel(p: OutletPlacement, size: number): THREE.BufferGeomet OUTLET_FLARE_HEIGHT, OUTLET_RADIAL_SEGMENTS, ).toNonIndexed() + applyCylinderWorldUvs(funnel, p.outer.halfX * OUTLET_FLARE_SCALE, OUTLET_FLARE_HEIGHT) } else { funnel = new THREE.BoxGeometry( 2 * p.outer.halfX * OUTLET_FLARE_SCALE, OUTLET_FLARE_HEIGHT, 2 * p.outer.halfZ * OUTLET_FLARE_SCALE, ).toNonIndexed() + applyPlanarWorldUvs(funnel) } funnel.translate(p.x, centerY, p.z) return funnel diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index b3684219a8..f08de5cfb3 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -70,6 +71,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -232,13 +234,27 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { // visible face. FrontSide is therefore sufficient and DoubleSide is not // needed. const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { + if (!textures) { + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + } + const slotMaterial = resolveMaterialRef(node.slots?.surface, sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (!node.material && !node.materialPreset) { return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots?.surface, + node.material, + node.materialPreset, + sceneMaterials, + ]) // Map gutter-local geometry into the host segment's local frame (where the // trim cut prisms live) — same pose the inner mesh group is mounted with diff --git a/packages/nodes/src/gutter/uv.test.ts b/packages/nodes/src/gutter/uv.test.ts new file mode 100644 index 0000000000..e96d994c05 --- /dev/null +++ b/packages/nodes/src/gutter/uv.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { GutterNode } from '@pascal-app/core' +import { buildGutterGeometry } from './geometry' + +describe('gutter UVs', () => { + test('preserves metre scale along the gutter run', () => { + const geometry = buildGutterGeometry( + GutterNode.parse({ + id: 'gutter_uv', + type: 'gutter', + length: 4, + hangerStyle: 'none', + }), + ) + const uv = geometry.getAttribute('uv') + expect(geometry.getAttribute('uv2').count).toBe(uv.count) + const values = Array.from({ length: uv.count }, (_, index) => [ + uv.getX(index), + uv.getY(index), + ]).flat() + + expect(Math.max(...values) - Math.min(...values)).toBeGreaterThanOrEqual(3.9) + }) +}) diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index fa8b93c186..43a36b634e 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -108,11 +108,9 @@ export function buildRoofSegmentFloorplan( // NOTE: the ridge / hip / break / slope linework is NOT drawn here — the // parent roof's builder (`buildRoofFloorplan`) draws it for every segment, - // clipped against the merged-roof valleys so a segment's ridge stops at - // the junction instead of running on into a neighbour it overlaps. This - // builder owns only the per-segment interaction chrome below. The shape - // math lives in `getRoofSegmentPlanLinework` (exported for the roof - // builder to consume). + // while this builder owns only the per-segment interaction chrome below. + // The shape math lives in `getRoofSegmentPlanLinework` (exported for the + // roof builder to consume). // Selection chrome — orange move-handle dot at the centre, four // perpendicular side resize-arrows (width on X, depth on Z), and a @@ -192,8 +190,8 @@ export type PlanSeg = readonly [PlanPt, PlanPt] * - break: horizontal fold where the slope angle changes (gambrel kink, * mansard/dutch waist) * - * Exported so the roof-level builder can reuse it to terminate the valley - * diagonals it draws at merged-roof junctions against the segments' ridges. + * Exported so the roof-level builder can reuse the same architectural + * linework for the complete roof plan. */ export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { ridges: PlanSeg[] diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index a88bd45f45..0f5ee3c4e9 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -7,9 +7,11 @@ import { isAutoGutterEnabled, isAutoRidgeVentEnabled, isDefaultRidgeVentNode, + normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, + type RoofSegmentTrim, type RoofType, useScene, } from '@pascal-app/core' @@ -25,7 +27,7 @@ import { useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Copy, Move, Trash2 } from 'lucide-react' +import { Check, Copy, Move, Pencil, RotateCcw, Trash2 } from 'lucide-react' import { useCallback } from 'react' const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [ @@ -50,6 +52,29 @@ const PITCH_PRESETS: { label: string; deg: number }[] = [ { label: '12/12', deg: 45 }, ] +const EMPTY_TRIM: RoofSegmentTrim = { + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, +} + +function hasSegmentTrim(node: RoofSegmentNode): boolean { + return Object.values(normalizeRoofSegmentTrim(node)).some((value) => value > 0) +} + function shouldShowTrimPlanes(metadata: unknown): boolean { return metadataRecord(metadata).showTrimPlanes === true } @@ -122,15 +147,25 @@ export default function RoofSegmentPanel() { ) const handleClose = useCallback(() => { + if (node && shouldShowTrimPlanes(node.metadata)) { + updateNode(node.id, { + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: false }, + }) + } setSelection({ selectedIds: [] }) - }, [setSelection]) + }, [node, setSelection, updateNode]) const handleBack = useCallback(() => { if (node?.parentId) { + if (shouldShowTrimPlanes(node.metadata)) { + updateNode(node.id, { + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: false }, + }) + } setRoofHostDragArmedId(node.parentId as AnyNodeId) setSelection({ selectedIds: [node.parentId] }) } - }, [node?.parentId, setRoofHostDragArmedId, setSelection]) + }, [node, setRoofHostDragArmedId, setSelection, updateNode]) const handleDuplicate = useCallback(() => { if (!node?.parentId) return @@ -232,6 +267,23 @@ export default function RoofSegmentPanel() { [selectedId], ) + const handleTrimEditing = useCallback( + (editing: boolean) => { + if (!node) return + triggerSFX('sfx:item-pick') + handleUpdate({ + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: editing }, + }) + }, + [handleUpdate, node], + ) + + const handleResetTrim = useCallback(() => { + if (!node || !hasSegmentTrim(node)) return + triggerSFX('sfx:item-pick') + handleUpdate({ trim: EMPTY_TRIM }) + }, [handleUpdate, node]) + if (!(node && node.type === 'roof-segment' && selectedId)) return null const showTrimPlanes = shouldShowTrimPlanes(node.metadata) @@ -261,15 +313,26 @@ export default function RoofSegmentPanel() { - - handleUpdate({ - metadata: { ...metadataRecord(node.metadata), showTrimPlanes: checked }, - }) - } - /> + + + ) : ( + + ) + } + label={showTrimPlanes ? 'Done editing' : 'Edit footprint'} + onClick={() => (showTrimPlanes ? handleBack() : handleTrimEditing(true))} + /> + } + label="Reset" + onClick={handleResetTrim} + /> + {node.roofType !== 'shed' && node.roofType !== 'flat' && ( , + children: AnyNode[], + siblings: AnyNode[], + nodes: Record, +): GeometryContext { + return { + resolve: (id: AnyNodeId) => nodes[id] as N | undefined, + children, + siblings, + parent: null, + } +} + +function outlinePoints(geometry: FloorplanGeometry | null): [number, number][] { + if (geometry?.kind !== 'group') return [] + return geometry.children.flatMap((child) => + child.kind === 'polygon' && child.fill === 'none' ? (child.points as [number, number][]) : [], + ) +} + +describe('buildRoofFloorplan roof intersections', () => { + test('clips the smaller roof footprint and keeps the larger host outline', () => { + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + children: ['rseg_host'], + }) + const enteringRoof = RoofNode.parse({ + id: 'roof_entering', + type: 'roof', + position: [3, 0, 0], + children: ['rseg_entering'], + }) + const hostSegment = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + }) + const enteringSegment = RoofSegmentNode.parse({ + id: 'rseg_entering', + type: 'roof-segment', + parentId: enteringRoof.id, + roofType: 'gable', + width: 8, + depth: 4, + }) + const nodes = { + [hostRoof.id]: hostRoof, + [enteringRoof.id]: enteringRoof, + [hostSegment.id]: hostSegment, + [enteringSegment.id]: enteringSegment, + } + + const enteringGeometry = buildRoofFloorplan( + enteringRoof, + buildContext(enteringRoof, [enteringSegment], [hostRoof], nodes), + ) + const enteringOutline = outlinePoints(enteringGeometry) + expect(Math.min(...enteringOutline.map(([x]) => x))).toBeCloseTo(5, 6) + expect(Math.max(...enteringOutline.map(([x]) => x))).toBeCloseTo(7, 6) + + const hostGeometry = buildRoofFloorplan( + hostRoof, + buildContext(hostRoof, [hostSegment], [enteringRoof], nodes), + ) + const hostOutline = outlinePoints(hostGeometry) + expect(Math.min(...hostOutline.map(([x]) => x))).toBeCloseTo(-5, 6) + expect(Math.max(...hostOutline.map(([x]) => x))).toBeCloseTo(5, 6) + }) +}) diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts index 2346281063..02a6eec796 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -1,84 +1,18 @@ -import type { - FloorplanGeometry, - FloorplanPoint, - GeometryContext, - RoofNode, - RoofSegmentNode, +import { + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + type RoofNode, + type RoofSegmentNode, + roofOverlapEntryOwns, + subtractPolygonsFromPolygon, + unionPolygons, } from '@pascal-app/core' -import { unionPolygons } from '@pascal-app/viewer' import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan' type Pt = [number, number] type Seg = [Pt, Pt] -function signedArea(ring: readonly Pt[]): number { - let a = 0 - const n = ring.length - for (let i = 0; i < n; i++) { - const p = ring[i] as Pt - const q = ring[(i + 1) % n] as Pt - a += p[0] * q[1] - q[0] * p[1] - } - return a / 2 -} - -/** Distance `t >= 0` from `V` along unit dir `(dx,dz)` to where the ray first - * meets segment `A→B`, or null. (Used to terminate valleys at ridges.) */ -function rayHitT( - vx: number, - vz: number, - dx: number, - dz: number, - ax: number, - az: number, - bx: number, - bz: number, -): number | null { - const ex = bx - ax - const ez = bz - az - const denom = dx * ez - dz * ex - if (Math.abs(denom) < 1e-9) return null - const wx = ax - vx - const wz = az - vz - const t = (wx * ez - wz * ex) / denom - const s = (wx * dz - wz * dx) / denom - if (t < 0) return null - if (s < -1e-6 || s > 1 + 1e-6) return null - return t -} - -function pointInPolygon(px: number, pz: number, poly: readonly Pt[]): boolean { - let inside = false - for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { - const pi = poly[i] as Pt - const pj = poly[j] as Pt - if ( - pi[1] > pz !== pj[1] > pz && - px < ((pj[0] - pi[0]) * (pz - pi[1])) / (pj[1] - pi[1]) + pi[0] - ) { - inside = !inside - } - } - return inside -} - -/** Parametric `t` in (0,1) along `p1→p2` where it crosses segment `a→b`, else null. */ -function segCrossT(p1: Pt, p2: Pt, a: Pt, b: Pt): number | null { - const rx = p2[0] - p1[0] - const rz = p2[1] - p1[1] - const ex = b[0] - a[0] - const ez = b[1] - a[1] - const denom = rx * ez - rz * ex - if (Math.abs(denom) < 1e-12) return null - const wx = a[0] - p1[0] - const wz = a[1] - p1[1] - const t = (wx * ez - wz * ex) / denom - const s = (wx * rz - wz * rx) / denom - if (t <= 1e-4 || t >= 1 - 1e-9) return null - if (s < -1e-6 || s > 1 + 1e-6) return null - return t -} - type SegPlan = { footprint: Pt[] ridges: Seg[] @@ -87,6 +21,12 @@ type SegPlan = { slope: { tail: Pt; head: Pt } | null } +type PlanEntry = { + roof: RoofNode + segment: RoofSegmentNode + plan: SegPlan +} + /** A segment's footprint + ridge/hip/break/slope linework, in world plan coords. */ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { const cosRoof = Math.cos(-roof.rotation) @@ -121,17 +61,67 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { } } +function pointInPolygon(point: Pt, polygon: Pt[]): boolean { + let inside = false + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { + const [x, y] = polygon[index]! + const [px, py] = polygon[previous]! + if (y > point[1] === py > point[1]) continue + const crossingX = ((px - x) * (point[1] - y)) / (py - y) + x + if (point[0] < crossingX) inside = !inside + } + return inside +} + +function segmentIntersectionParameter(line: Seg, edge: Seg): number | null { + const lineX = line[1][0] - line[0][0] + const lineY = line[1][1] - line[0][1] + const edgeX = edge[1][0] - edge[0][0] + const edgeY = edge[1][1] - edge[0][1] + const determinant = lineX * edgeY - lineY * edgeX + if (Math.abs(determinant) <= 1e-9) return null + const offsetX = edge[0][0] - line[0][0] + const offsetY = edge[0][1] - line[0][1] + const lineT = (offsetX * edgeY - offsetY * edgeX) / determinant + const edgeT = (offsetX * lineY - offsetY * lineX) / determinant + return lineT > 1e-9 && lineT < 1 - 1e-9 && edgeT >= -1e-9 && edgeT <= 1 + 1e-9 ? lineT : null +} + +function clipLineByCutters(line: Seg, cutters: Pt[][]): Seg[] { + const parameters = [0, 1] + for (const cutter of cutters) { + for (let index = 0; index < cutter.length; index++) { + const parameter = segmentIntersectionParameter(line, [ + cutter[index]!, + cutter[(index + 1) % cutter.length]!, + ]) + if (parameter !== null) parameters.push(parameter) + } + } + parameters.sort((a, b) => a - b) + + const dx = line[1][0] - line[0][0] + const dy = line[1][1] - line[0][1] + const result: Seg[] = [] + for (let index = 0; index < parameters.length - 1; index++) { + const startT = parameters[index]! + const endT = parameters[index + 1]! + if (endT - startT <= 1e-9) continue + const midT = (startT + endT) / 2 + const midpoint: Pt = [line[0][0] + dx * midT, line[0][1] + dy * midT] + if (cutters.some((cutter) => pointInPolygon(midpoint, cutter))) continue + result.push([ + [line[0][0] + dx * startT, line[0][1] + dy * startT], + [line[0][0] + dx * endT, line[0][1] + dy * endT], + ]) + } + return result +} + /** * Roof-level floor-plan builder. Draws the whole merged-roof plan: the - * unioned silhouette, the valley diagonals at concave junctions, and every - * segment's ridge/hip/break linework — clipped so a line stops at the valley - * where its segment overlaps a neighbour, instead of running on at the - * segment's full length into the cut-away part. - * - * Drawing all the linework here (rather than per-segment) is what lets the - * clip work: the valleys and the neighbouring footprints are all in hand, so - * each line can be trimmed to the actual merged geometry. The segment - * builder keeps only its hit-target / selection chrome. + * unioned silhouette and every segment's ridge/hip/break linework. The + * segment builder keeps only its hit-target / selection chrome. * * Composition uses the floor plan's negated-rotation convention * (segment-local → roof-local → plan). `unionPolygons` returns one ring per @@ -143,49 +133,51 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp const segments = ctx.children.filter((c): c is RoofSegmentNode => c.type === 'roof-segment') if (segments.length === 0) return null - const plans = segments.map((s) => buildSegPlan(node, s)) - const rings = unionPolygons(plans.map((p) => p.footprint)) as Pt[][] - if (rings.length === 0) return null - - // Valleys at concave (reflex) corners of the merged outline. Each runs - // along the interior angle bisector and terminates at the nearest segment - // ridge — the diagonal where two merged slopes meet. - const allRidges: Seg[] = plans.flatMap((p) => p.ridges) - const valleys: Seg[] = [] - for (const ring of rings) { - const n = ring.length - if (n < 3) continue - const orient = signedArea(ring) > 0 ? 1 : -1 - for (let i = 0; i < n; i++) { - const prev = ring[(i - 1 + n) % n] as Pt - const V = ring[i] as Pt - const next = ring[(i + 1) % n] as Pt - const ax = prev[0] - V[0] - const az = prev[1] - V[1] - const bx = next[0] - V[0] - const bz = next[1] - V[1] - if ((ax * bz - az * bx) * orient <= 0) continue // not reflex - const la = Math.hypot(ax, az) || 1 - const lb = Math.hypot(bx, bz) || 1 - let dx = -(ax / la + bx / lb) - let dz = -(az / la + bz / lb) - const dl = Math.hypot(dx, dz) - if (dl < 1e-6) continue - dx /= dl - dz /= dl - let bestT = Number.POSITIVE_INFINITY - for (const [A, B] of allRidges) { - const t = rayHitT(V[0], V[1], dx, dz, A[0], A[1], B[0], B[1]) - if (t !== null && t > 1e-4 && t < bestT) bestT = t - } - if (!Number.isFinite(bestT)) continue - valleys.push([ - [V[0], V[1]], - [V[0] + dx * bestT, V[1] + dz * bestT], - ]) + const entries: PlanEntry[] = segments.map((segment) => ({ + roof: node, + segment, + plan: buildSegPlan(node, segment), + })) + for (const sibling of ctx.siblings) { + if (sibling.type !== 'roof') continue + for (const childId of sibling.children ?? []) { + const segment = ctx.resolve(childId) + if (segment?.type !== 'roof-segment') continue + entries.push({ roof: sibling, segment, plan: buildSegPlan(sibling, segment) }) } } + const currentEntries = entries.filter((entry) => entry.roof.id === node.id) + const visiblePlans = currentEntries.map((entry) => { + const cutters = entries + .filter((candidate) => { + if (candidate.segment.id === entry.segment.id) return false + if (candidate.segment.roofType === 'shed') return false + return roofOverlapEntryOwns( + { + roofId: String(candidate.roof.id), + segmentId: String(candidate.segment.id), + width: candidate.segment.width, + depth: candidate.segment.depth, + }, + { + roofId: String(entry.roof.id), + segmentId: String(entry.segment.id), + width: entry.segment.width, + depth: entry.segment.depth, + }, + ) + }) + .map((candidate) => candidate.plan.footprint) + return { + plan: entry.plan, + cutters, + footprints: subtractPolygonsFromPolygon(entry.plan.footprint, cutters) as Pt[][], + } + }) + const rings = unionPolygons(visiblePlans.flatMap(({ footprints }) => footprints)) as Pt[][] + if (rings.length === 0) return null + const view = ctx.viewState const palette = view?.palette const showSelectedChrome = (view?.selected ?? false) || (view?.highlighted ?? false) @@ -223,62 +215,42 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp }) } - // Valley diagonals. - for (const v of valleys) pushLine(v[0], v[1], hipWidth) - - // Per-segment ridge / hip / break linework, clipped to the merged geometry: - // an endpoint that overshoots into another segment is pulled back to the - // valley it crosses (the junction), so a ridge stops at the diagonal. - const footprints = plans.map((p) => p.footprint) - const clipEnd = (pt: Pt, other: Pt, ownIndex: number): Pt => { - let inOther = false - for (let i = 0; i < footprints.length; i++) { - if (i === ownIndex) continue - if (pointInPolygon(pt[0], pt[1], footprints[i] as Pt[])) { - inOther = true - break + for (const { plan, cutters } of visiblePlans) { + for (const line of plan.breaks) { + for (const visible of clipLineByCutters(line, cutters)) { + pushLine(visible[0], visible[1], hipWidth) } } - if (!inOther) return pt - let bestT = Number.POSITIVE_INFINITY // nearest valley crossing to the overshoot - for (const v of valleys) { - const t = segCrossT(pt, other, v[0], v[1]) - if (t !== null && t < bestT) bestT = t + for (const line of plan.hips) { + for (const visible of clipLineByCutters(line, cutters)) { + pushLine(visible[0], visible[1], hipWidth) + } + } + for (const line of plan.ridges) { + for (const visible of clipLineByCutters(line, cutters)) { + pushLine(visible[0], visible[1], ridgeWidth) + } } - if (!Number.isFinite(bestT)) return pt // overshoots but no valley to stop at - return [pt[0] + (other[0] - pt[0]) * bestT, pt[1] + (other[1] - pt[1]) * bestT] - } - const clipPush = (line: Seg, width: number, ownIndex: number) => { - const a = clipEnd(line[0], line[1], ownIndex) - const b = clipEnd(line[1], a, ownIndex) - const dx = a[0] - b[0] - const dz = a[1] - b[1] - if (dx * dx + dz * dz < 1e-8) return - pushLine(a, b, width) - } - - plans.forEach((p, idx) => { - for (const s of p.breaks) clipPush(s, hipWidth, idx) - for (const s of p.hips) clipPush(s, hipWidth, idx) - for (const s of p.ridges) clipPush(s, ridgeWidth, idx) - // Shed downslope arrow (no overshoot to clip). - if (p.slope) { - const { tail, head } = p.slope - const dx = head[0] - tail[0] - const dz = head[1] - tail[1] + if (plan.slope) { + const { tail, head } = plan.slope + const visibleSlope = clipLineByCutters([tail, head], cutters).at(-1) + if (!visibleSlope) continue + const [visibleTail, visibleHead] = visibleSlope + const dx = visibleHead[0] - visibleTail[0] + const dz = visibleHead[1] - visibleTail[1] const len = Math.hypot(dx, dz) || 1 const ux = dx / len const uz = dz / len const headLen = Math.min(0.22, len * 0.4) const wing = headLen * 0.6 - pushLine(tail, head, hipWidth) + pushLine(visibleTail, visibleHead, hipWidth) children.push({ kind: 'polyline', points: [ - [head[0] - headLen * ux - wing * uz, head[1] - headLen * uz + wing * ux], - [head[0], head[1]], - [head[0] - headLen * ux + wing * uz, head[1] - headLen * uz - wing * ux], + [visibleHead[0] - headLen * ux - wing * uz, visibleHead[1] - headLen * uz + wing * ux], + [visibleHead[0], visibleHead[1]], + [visibleHead[0] - headLen * ux + wing * uz, visibleHead[1] - headLen * uz - wing * ux], ], stroke: ink, strokeWidth: hipWidth, @@ -287,7 +259,7 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp pointerEvents: 'none', }) } - }) + } return children.length > 0 ? { kind: 'group', children } : null } diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index c33ddfbe2c..a14400bc43 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -10,7 +10,6 @@ import { type RidgeVentNode, type RoofNode, type RoofSegmentNode, - RoofSegmentNode as RoofSegmentNodeSchema, type SkylightNode, type SolarPanelNode, type TurbineVentNode, @@ -37,7 +36,6 @@ export default function RoofPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) - const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => @@ -168,17 +166,11 @@ export default function RoofPanel() { const handleAddSegment = useCallback(() => { if (!node) return - const segment = RoofSegmentNodeSchema.parse({ - width: 6, - depth: 6, - wallHeight: 0.5, - pitch: 40, - roofType: 'gable', - position: [2, 0, 2], - metadata: { autoRidgeVent: false }, - }) - createNodes([{ node: segment, parentId: node.id as AnyNodeId }]) - }, [node, createNodes]) + triggerSFX('sfx:item-pick') + const editor = useEditor.getState() + editor.setTool('roof') + if (editor.mode !== 'build') editor.setMode('build') + }, [node]) const handleSelectSegment = useCallback( (segmentId: string) => { @@ -272,7 +264,7 @@ export default function RoofPanel() { } - label="Add Segment" + label="Draw Segment" onClick={handleAddSegment} /> diff --git a/packages/nodes/src/roof/renderer.tsx b/packages/nodes/src/roof/renderer.tsx index 45a288711d..1dad773f6e 100644 --- a/packages/nodes/src/roof/renderer.tsx +++ b/packages/nodes/src/roof/renderer.tsx @@ -92,7 +92,6 @@ export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => { const material = debugColors ? getRoofDebugMaterials(shading) : customMaterial || getRoofMaterials(shading, textures, colorPreset) - useEffect(() => { return () => { placeholderGeometry.dispose() diff --git a/packages/nodes/src/shared/primitive-uv.test.ts b/packages/nodes/src/shared/primitive-uv.test.ts new file mode 100644 index 0000000000..a1077d8f70 --- /dev/null +++ b/packages/nodes/src/shared/primitive-uv.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test' +import { BoxGeometry, CylinderGeometry, SphereGeometry } from 'three' +import { + applyCylinderWorldUvs, + applyPlanarWorldUvs, + applySphereWorldUvs, + cumulativeProfileDistances, + planarMetricUvs, +} from './primitive-uv' + +function span(values: number[]): number { + return Math.max(...values) - Math.min(...values) +} + +describe('primitive world-scale UVs', () => { + test('measures sampled curved profiles in metres', () => { + expect( + cumulativeProfileDistances([ + [0, 0, 0], + [0.3, 0.4, 0], + [0.3, 0.4, 1], + ]), + ).toEqual([0, 0.5, 1.5]) + }) + + test('projects trapezoids at their physical size', () => { + expect( + planarMetricUvs( + [ + [0, 0, 0], + [2, 0, 0], + [1.5, 1, 0], + [0.5, 1, 0], + ], + [0, 0, 1], + ), + ).toEqual([ + [0, 0], + [2, 0], + [1.5, 1], + [0.5, 1], + ]) + }) + + test('maps an axis-aligned box in metres', () => { + const geometry = new BoxGeometry(2, 3, 4).toNonIndexed() + applyPlanarWorldUvs(geometry) + const position = geometry.getAttribute('position') + const uv = geometry.getAttribute('uv') + + for (let triangle = 0; triangle < position.count; triangle += 3) { + for (const [from, to] of [ + [0, 1], + [1, 2], + [2, 0], + ] as const) { + const a = triangle + from + const b = triangle + to + const worldLength = Math.hypot( + position.getX(b) - position.getX(a), + position.getY(b) - position.getY(a), + position.getZ(b) - position.getZ(a), + ) + const uvLength = Math.hypot(uv.getX(b) - uv.getX(a), uv.getY(b) - uv.getY(a)) + expect(uvLength).toBeCloseTo(worldLength) + } + } + }) + + test('unwraps cylinder sides by circumference and height', () => { + const radius = 0.5 + const height = 3 + const geometry = new CylinderGeometry(radius, radius, height, 16).toNonIndexed() + applyCylinderWorldUvs(geometry, radius, height) + const normal = geometry.getAttribute('normal') + const uv = geometry.getAttribute('uv') + const sideU: number[] = [] + const sideV: number[] = [] + for (let index = 0; index < normal.count; index += 1) { + if (Math.abs(normal.getY(index)) >= 0.5) continue + sideU.push(uv.getX(index)) + sideV.push(uv.getY(index)) + } + + expect(span(sideU)).toBeCloseTo(Math.PI * 2 * radius) + expect(span(sideV)).toBeCloseTo(height) + }) + + test('unwraps a sphere by circumference and pole distance', () => { + const radius = 0.5 + const geometry = new SphereGeometry(radius, 12, 8).toNonIndexed() + applySphereWorldUvs(geometry, radius) + const uv = geometry.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + const v = Array.from({ length: uv.count }, (_, index) => uv.getY(index)) + + expect(span(u)).toBeCloseTo(Math.PI * 2 * radius) + expect(span(v)).toBeCloseTo(Math.PI * radius) + }) +}) diff --git a/packages/nodes/src/shared/primitive-uv.ts b/packages/nodes/src/shared/primitive-uv.ts new file mode 100644 index 0000000000..182e690274 --- /dev/null +++ b/packages/nodes/src/shared/primitive-uv.ts @@ -0,0 +1,133 @@ +import * as THREE from 'three' + +export type MetricUv = readonly [number, number] + +type Point3 = readonly [number, number, number] | number[] + +/** Return cumulative metre distances along a sampled open or closed profile. */ +export function cumulativeProfileDistances(points: readonly Point3[]): number[] { + const distances = [0] + for (let index = 1; index < points.length; index += 1) { + const previous = points[index - 1]! + const current = points[index]! + distances.push( + distances[index - 1]! + + Math.hypot( + current[0]! - previous[0]!, + current[1]! - previous[1]!, + current[2]! - previous[2]!, + ), + ) + } + return distances +} + +/** Project a flat polygon into metre-scaled UV coordinates without shearing trapezoids. */ +export function planarMetricUvs( + points: readonly Point3[], + normal: Point3, + uOffset = 0, + vOffset = 0, +): MetricUv[] { + const origin = points[0]! + const uTarget = points[1]! + const ux = uTarget[0]! - origin[0]! + const uy = uTarget[1]! - origin[1]! + const uz = uTarget[2]! - origin[2]! + const uLength = Math.hypot(ux, uy, uz) || 1 + const unitU = [ux / uLength, uy / uLength, uz / uLength] + const normalLength = Math.hypot(normal[0]!, normal[1]!, normal[2]!) || 1 + const unitNormal = [ + normal[0]! / normalLength, + normal[1]! / normalLength, + normal[2]! / normalLength, + ] + const unitV = [ + unitNormal[1]! * unitU[2]! - unitNormal[2]! * unitU[1]!, + unitNormal[2]! * unitU[0]! - unitNormal[0]! * unitU[2]!, + unitNormal[0]! * unitU[1]! - unitNormal[1]! * unitU[0]!, + ] + + return points.map((point) => { + const x = point[0]! - origin[0]! + const y = point[1]! - origin[1]! + const z = point[2]! - origin[2]! + return [ + uOffset + x * unitU[0]! + y * unitU[1]! + z * unitU[2]!, + vOffset + x * unitV[0]! + y * unitV[1]! + z * unitV[2]!, + ] as const + }) +} + +/** Reuse the authored unwrap for AO and light maps, which read texture channel 2. */ +export function copyUvToSecondaryChannel(geometry: THREE.BufferGeometry): void { + const uv = geometry.getAttribute('uv') + if (uv) geometry.setAttribute('uv2', uv.clone()) +} + +/** Apply metre-scaled planar UVs to a non-indexed, axis-aligned primitive. */ +export function applyPlanarWorldUvs(geometry: THREE.BufferGeometry): void { + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + const uvs = new Float32Array(position.count * 2) + + for (let triangle = 0; triangle < position.count; triangle += 3) { + const nx = Math.abs(normal.getX(triangle)) + const ny = Math.abs(normal.getY(triangle)) + const nz = Math.abs(normal.getZ(triangle)) + for (let corner = 0; corner < 3; corner += 1) { + const index = triangle + corner + const x = position.getX(index) + const y = position.getY(index) + const z = position.getZ(index) + if (ny >= nx && ny >= nz) { + uvs[index * 2] = x + uvs[index * 2 + 1] = z + } else if (nx >= nz) { + uvs[index * 2] = z + uvs[index * 2 + 1] = y + } else { + uvs[index * 2] = x + uvs[index * 2 + 1] = y + } + } + } + + geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) +} + +/** Scale cylinder/cone side UVs by circumference and height; caps use XZ metres. */ +export function applyCylinderWorldUvs( + geometry: THREE.BufferGeometry, + radius: number, + height: number, +): void { + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + const sourceUv = geometry.getAttribute('uv') + const uvs = new Float32Array(position.count * 2) + const circumference = Math.PI * 2 * radius + + for (let index = 0; index < position.count; index += 1) { + if (Math.abs(normal.getY(index)) < 0.5) { + uvs[index * 2] = sourceUv.getX(index) * circumference + uvs[index * 2 + 1] = (sourceUv.getY(index) - 0.5) * height + } else { + uvs[index * 2] = position.getX(index) + uvs[index * 2 + 1] = position.getZ(index) + } + } + + geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) +} + +/** Scale a sphere's equirectangular UVs to its circumference and pole distance. */ +export function applySphereWorldUvs(geometry: THREE.BufferGeometry, radius: number): void { + const sourceUv = geometry.getAttribute('uv') + const uvs = new Float32Array(sourceUv.count * 2) + for (let index = 0; index < sourceUv.count; index += 1) { + uvs[index * 2] = sourceUv.getX(index) * Math.PI * 2 * radius + uvs[index * 2 + 1] = sourceUv.getY(index) * Math.PI * radius + } + geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) +} diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index 22cbe9a3cd..afd0bf29e9 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -21,8 +21,8 @@ import { type Material, type Mesh, type Object3D, Raycaster } from 'three' * Shared paint capability for procedural kinds on the unified slot model * (`node.slots: Record` + the shared scene-material * palette) — the same data shape items derive from their GLB and the shelf - * declares via `capabilities.slots`. Distinct from `surface-paint.ts`, which - * writes the legacy inline `node.material` copy the plan is retiring. + * declares via `capabilities.slots`. `surface-paint.ts` configures this helper + * for kinds whose entire rendered subtree is one paintable surface. * * The commit / resolve / effective-material logic is identical across kinds; * only the slot-resolution from a pointer hit and the mesh preview differ, so diff --git a/packages/nodes/src/shared/surface-paint.ts b/packages/nodes/src/shared/surface-paint.ts index cc97dffef4..904d1135c7 100644 --- a/packages/nodes/src/shared/surface-paint.ts +++ b/packages/nodes/src/shared/surface-paint.ts @@ -1,38 +1,14 @@ -import type { AnyNode, MaterialSchema, PaintCapability } from '@pascal-app/core' -import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' -import type { Material, Mesh, Object3D } from 'three' +import type { AnyNode, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from './slot-paint' -/** - * Paint capability for kinds with a single painted surface (`role: 'surface'`) - * that register a `` of meshes all sharing one material — the roof - * vents (box / ridge / turbine / cupola / eyebrow). Replaces the editor's - * hardcoded `node.type === ''` paint arms with registry-driven dispatch, - * the same way chimney / dormer / wall declare their own `paint` capability. - */ +type LegacySurfaceNode = AnyNode & { material?: MaterialSchema; materialPreset?: string } -type SurfaceNode = AnyNode & { - material?: MaterialSchema - materialPreset?: string -} - -function buildPreviewMaterial( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Material | null { - if (materialPreset) return createMaterialFromPresetRef(materialPreset) - if (material) return createMaterial(material) - return null -} - -export const surfacePaintCapability: PaintCapability = { - // One paintable surface — every face resolves to it. +export const surfacePaintCapability = createSlotPaintCapability({ resolveRole: () => 'surface', - buildPatch: ({ material, materialPreset }) => ({ material, materialPreset }) as Partial, applyPreview: ({ material, materialPreset, root }) => { - const preview = buildPreviewMaterial(material, materialPreset) + const preview = buildSlotPreviewMaterial(material, materialPreset) if (!preview) return null - // The kinds register a group, so walk the subtree and swap every child - // mesh's material, recording a restore for each. const restores: Array<() => void> = [] ;(root as Object3D).traverse((object) => { const mesh = object as Mesh @@ -45,11 +21,11 @@ export const surfacePaintCapability: PaintCapability = { }) if (restores.length === 0) return null return () => { - for (let i = restores.length - 1; i >= 0; i -= 1) restores[i]?.() + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() } }, - getEffectiveMaterial: ({ node }) => { - const n = node as SurfaceNode - return { material: n.material, materialPreset: n.materialPreset } + legacyEffective: (node) => { + const legacy = node as LegacySurfaceNode + return { material: legacy.material, materialPreset: legacy.materialPreset } }, -} +}) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 7caa824f8f..21c58c622d 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -5,6 +5,7 @@ import { type SiteNode, type TerrainField, terrainFieldOf, + unionPolygons, useLiveNodeOverrides, useLiveTerrain, useRegistry, @@ -16,7 +17,6 @@ import { getSceneTheme, horizonHazeColor, NodeRenderer, - unionPolygons, useNodeEvents, useViewer, } from '@pascal-app/viewer' diff --git a/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts b/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts index 62337275fb..d99a3bc7e3 100644 --- a/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts @@ -19,6 +19,7 @@ describe('turbine vent geometry', () => { expect(positions.count).toBeGreaterThan(0) expect(normals.count).toBe(positions.count) expect(uvs.count).toBe(positions.count) + expect(geo.getAttribute('uv2').count).toBe(positions.count) }) test('base and head both produce finite, non-empty geometry', () => { @@ -31,6 +32,16 @@ describe('turbine vent geometry', () => { expect(allFinite(head)).toBe(true) }) + test('unwraps the circular base continuously at metre scale', () => { + const base = buildTurbineVentBase( + TurbineVentNode.parse({ diameter: 2, baseOverhang: 0.1, neckHeight: 0.5, height: 2 }), + ) + const uv = base.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(6.5) + }) + test('both styles build finite geometry', () => { for (const style of ['globe', 'cylinder'] as const) { const geo = buildTurbineVentGeometry(TurbineVentNode.parse({ style })) diff --git a/packages/nodes/src/turbine-vent/__tests__/paint.test.ts b/packages/nodes/src/turbine-vent/__tests__/paint.test.ts new file mode 100644 index 0000000000..164725153d --- /dev/null +++ b/packages/nodes/src/turbine-vent/__tests__/paint.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' +import { Group, Mesh, MeshBasicMaterial } from 'three' +import { resolveTurbineVentMaterialRole, turbineVentPaint } from '../paint' +import { TurbineVentNode } from '../schema' + +describe('turbine vent paint', () => { + test('maps the fixed and spinning meshes to separate roles', () => { + expect(resolveTurbineVentMaterialRole('turbine-vent-base')).toBe('base') + expect(resolveTurbineVentMaterialRole('turbine-vent-head')).toBe('head') + }) + + test('updates one role and falls back to the legacy whole-vent material', () => { + const node = TurbineVentNode.parse({ slots: { base: 'library:steel' } }) + expect( + turbineVentPaint.buildPatch({ + node, + role: 'head', + material: undefined, + materialPreset: 'library:copper', + }), + ).toEqual({ + slots: { base: 'library:steel', head: 'library:copper' }, + }) + expect( + turbineVentPaint.getEffectiveMaterial?.({ node, role: 'base', nodes: {} })?.materialPreset, + ).toBe('library:steel') + expect( + turbineVentPaint.getEffectiveMaterial?.({ node, role: 'head', nodes: {} })?.materialPreset, + ).toBe('preset-white') + }) + + test('previews only the selected mesh', () => { + const baseMaterial = new MeshBasicMaterial() + const headMaterial = new MeshBasicMaterial() + const base = new Mesh(undefined, baseMaterial) + const head = new Mesh(undefined, headMaterial) + base.name = 'turbine-vent-base' + head.name = 'turbine-vent-head' + const root = new Group() + root.add(base, head) + const restore = turbineVentPaint.applyPreview({ + node: TurbineVentNode.parse({}), + role: 'head', + material: { + preset: 'custom', + properties: { + color: '#123456', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + materialPreset: undefined, + root, + }) + expect(base.material).toBe(baseMaterial) + expect(head.material).not.toBe(headMaterial) + restore?.() + expect(head.material).toBe(headMaterial) + }) +}) diff --git a/packages/nodes/src/turbine-vent/definition.ts b/packages/nodes/src/turbine-vent/definition.ts index 995d9618cc..c48ecde0e6 100644 --- a/packages/nodes/src/turbine-vent/definition.ts +++ b/packages/nodes/src/turbine-vent/definition.ts @@ -4,8 +4,8 @@ import { TurbineVentNode as TurbineVentNodeSchema, type TurbineVentNode as TurbineVentNodeType, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildTurbineVentFloorplan } from './floorplan' +import { turbineVentPaint } from './paint' import { turbineVentParametrics } from './parametrics' import { TurbineVentNode } from './schema' @@ -81,7 +81,7 @@ const turbineVentHandles: HandleDescriptor[] = [ */ export const turbineVentDefinition: NodeDefinition = { kind: 'turbine-vent', - schemaVersion: 1, + schemaVersion: 3, schema: TurbineVentNode, category: 'structure', surfaceRole: 'roof', @@ -93,11 +93,14 @@ export const turbineVentDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'base', label: 'Base', default: 'library:preset-softwhite' }, + { slotId: 'head', label: 'Head', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: turbineVentPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // slope — no `buildCut`, just the dirty cascade so the parent roof's // merged shell rebuilds when the vent moves / resizes. diff --git a/packages/nodes/src/turbine-vent/geometry.ts b/packages/nodes/src/turbine-vent/geometry.ts index 579dbb6b7a..01edb0ec38 100644 --- a/packages/nodes/src/turbine-vent/geometry.ts +++ b/packages/nodes/src/turbine-vent/geometry.ts @@ -1,6 +1,12 @@ import type { TurbineVentNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { + copyUvToSecondaryChannel, + cumulativeProfileDistances, + type MetricUv, + planarMetricUvs, +} from '../shared/primitive-uv' /** * Pure builders for the turbine vent (whirlybird). The mesh is split into @@ -206,6 +212,11 @@ function cylinderWall( y1: number, segs: number, ): void { + const ring = Array.from({ length: segs + 1 }, (_, index) => { + const angle = (index / segs) * Math.PI * 2 + return [r * Math.cos(angle), y0, r * Math.sin(angle)] + }) + const ringU = cumulativeProfileDistances(ring) for (let i = 0; i < segs; i++) { const a = (i / segs) * Math.PI * 2 const b = ((i + 1) / segs) * Math.PI * 2 @@ -223,6 +234,12 @@ function cylinderWall( [r * cb, y1, r * sb], [r * ca, y1, r * sa], out, + [ + [ringU[i]!, y0], + [ringU[i + 1]!, y0], + [ringU[i + 1]!, y1], + [ringU[i]!, y1], + ], ) } } @@ -240,7 +257,13 @@ function disc( for (let i = 0; i < segs; i++) { const a = (i / segs) * Math.PI * 2 const b = ((i + 1) / segs) * Math.PI * 2 - pushTri(p, n, uv, center, polar(a, r, y), polar(b, r, y), hint) + const edgeA = polar(a, r, y) + const edgeB = polar(b, r, y) + pushTri(p, n, uv, center, edgeA, edgeB, hint, [ + [center.x, center.z], + [edgeA.x, edgeA.z], + [edgeB.x, edgeB.z], + ]) } } @@ -268,6 +291,15 @@ function dome( grid.push(row) } const center = new THREE.Vector3(0, y0, 0) + const ringU = grid.map((row) => + cumulativeProfileDistances(row.map((point) => [point.x, point.y, point.z])), + ) + const ringV = [0] + for (let i = 1; i <= lat; i++) { + let distance = 0 + for (let j = 0; j <= lng; j++) distance += grid[i - 1]![j]!.distanceTo(grid[i]![j]!) + ringV.push(ringV[i - 1]! + distance / (lng + 1)) + } for (let i = 0; i < lat; i++) { for (let j = 0; j < lng; j++) { const a = grid[i]![j]! @@ -276,7 +308,22 @@ function dome( const d = grid[i + 1]![j]! const mid = new THREE.Vector3().add(a).add(b).add(c).add(d).multiplyScalar(0.25) const hint = mid.clone().sub(center).normalize() - pushQuad(p, n, uv, a, b, c, d, [hint.x, hint.y, hint.z]) + pushQuad( + p, + n, + uv, + a, + b, + c, + d, + [hint.x, hint.y, hint.z], + [ + [ringU[i]![j]!, ringV[i]!], + [ringU[i]![j + 1]!, ringV[i]!], + [ringU[i + 1]![j + 1]!, ringV[i + 1]!], + [ringU[i + 1]![j]!, ringV[i + 1]!], + ], + ) } } } @@ -314,6 +361,7 @@ function pushQuad( cp: THREE.Vector3 | number[], dp: THREE.Vector3 | number[], hint: [number, number, number], + authoredUvs?: readonly MetricUv[], ): void { const a = v(ap) const b = v(bp) @@ -334,27 +382,21 @@ function pushQuad( ny /= len nz /= len - const abx = b[0]! - a[0]! - const aby = b[1]! - a[1]! - const abz = b[2]! - a[2]! - const adx = d[0]! - a[0]! - const ady = d[1]! - a[1]! - const adz = d[2]! - a[2]! - const u = Math.sqrt(abx * abx + aby * aby + abz * abz) - const vv = Math.sqrt(adx * adx + ady * ady + adz * adz) + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c, d], [nx, ny, nz]) + const [uvA, uvB, uvC, uvD] = faceUvs as readonly [MetricUv, MetricUv, MetricUv, MetricUv] if (flip) { // Reversed winding: (a,b,c) + (a,c,d). positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, vv) + uvs.push(...uvA, ...uvB, ...uvC) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, vv, 0, vv) + uvs.push(...uvA, ...uvC, ...uvD) } else { // Default winding: (a,c,b) + (a,d,c). positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, vv, u, 0) + uvs.push(...uvA, ...uvC, ...uvB) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, vv, u, vv) + uvs.push(...uvA, ...uvD, ...uvC) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -367,6 +409,7 @@ function pushTri( bp: THREE.Vector3 | number[], cp: THREE.Vector3 | number[], hint: [number, number, number], + authoredUvs?: readonly MetricUv[], ): void { const a = v(ap) const b = v(bp) @@ -385,12 +428,17 @@ function pushTri( ny /= len nz /= len + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c], [nx, ny, nz]) + const uvA = faceUvs[0]! + const uvB = faceUvs[1]! + const uvC = faceUvs[2]! if (flip) { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) + uvs.push(...uvA, ...uvC, ...uvB) } else { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) + uvs.push(...uvA, ...uvB, ...uvC) } - uvs.push(0, 0, 1, 0, 0, 1) for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } @@ -399,6 +447,7 @@ function toGeometry(positions: number[], normals: number[], uvs: number[]): THRE geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + copyUvToSecondaryChannel(geo) geo.computeBoundingSphere() return geo } diff --git a/packages/nodes/src/turbine-vent/paint.ts b/packages/nodes/src/turbine-vent/paint.ts new file mode 100644 index 0000000000..d689ec4a31 --- /dev/null +++ b/packages/nodes/src/turbine-vent/paint.ts @@ -0,0 +1,37 @@ +import type { AnyNode, MaterialSchema, TurbineVentMaterialRole } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' + +type LegacyTurbineVent = AnyNode & { material?: MaterialSchema; materialPreset?: string } + +export function resolveTurbineVentMaterialRole(hitObjectName?: string): TurbineVentMaterialRole { + return hitObjectName === 'turbine-vent-head' ? 'head' : 'base' +} + +export const turbineVentPaint = createSlotPaintCapability({ + materialTarget: 'turbine-vent', + resolveRole: ({ hitObjectName }) => resolveTurbineVentMaterialRole(hitObjectName), + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return null + const targetName = `turbine-vent-${role}` + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== targetName) return + const previous = mesh.material + mesh.material = preview + restores.push(() => { + mesh.material = previous + }) + }) + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } + }, + legacyEffective: (node) => { + const legacy = node as LegacyTurbineVent + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index e430cd63f9..992bb9c8f6 100644 --- a/packages/nodes/src/turbine-vent/renderer.tsx +++ b/packages/nodes/src/turbine-vent/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -50,6 +51,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) // Merge live overrides (panel slider drags) on top of the store node so // the mesh updates frame-by-frame without polluting undo history. @@ -99,13 +101,31 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => }, [segment, node.position[0], node.position[2]]) const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const resolve = (role: 'base' | 'head') => { + if (!textures) return roleDefault + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return { + base: resolve('base'), + head: resolve('head'), + } + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, + ]) // Compose slope tilt + yaw onto a single quaternion so the registered // ref's local frame is vent-mesh-local (handles read this frame). @@ -163,7 +183,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => { + test('removes a roof layer that continues through a sibling attic', () => { + const layer = box([4, 0.2, 4], [0, 1, 0]) + const siblingInterior = box([2, 3, 2], [0, 1, 0]) + const evaluator = new Evaluator() + evaluator.attributes = ['position', 'normal', 'uv'] + + const result = subtractCsgBrush(layer, siblingInterior, evaluator) + const mesh = new THREE.Mesh(result.geometry) + const centerHits = new THREE.Raycaster( + new THREE.Vector3(0, 3, 0), + new THREE.Vector3(0, -1, 0), + ).intersectObject(mesh) + const edgeHits = new THREE.Raycaster( + new THREE.Vector3(1.5, 3, 0), + new THREE.Vector3(0, -1, 0), + ).intersectObject(mesh) + + expect(centerHits).toHaveLength(0) + expect(edgeHits.length).toBeGreaterThan(0) + + layer.geometry.dispose() + siblingInterior.geometry.dispose() + result.geometry.dispose() + }) + + test('clips a painted gable segment against its mansard sibling', () => { + const roof = RoofNode.parse({ + id: 'roof_join', + type: 'roof', + children: ['rseg_mansard', 'rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: roof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + position: [3, 0, 0], + rotation: Math.PI / 2, + materialPreset: 'library:roof-shingle', + }) + const nodes = { [roof.id]: roof, [mansard.id]: mansard, [gable.id]: gable } + + const unclipped = generateRoofSegmentGeometry(gable) + const clipped = generateRoofSegmentGeometry(gable, nodes) + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + const unclippedHits = ray.intersectObject(new THREE.Mesh(unclipped)) + const clippedHits = ray.intersectObject(new THREE.Mesh(clipped)) + expect(unclippedHits.length).toBeGreaterThan(0) + expect(clippedHits).toHaveLength(0) + + unclipped.dispose() + clipped.dispose() + }) + + test('keeps the host mansard shell beneath an entering gable', () => { + const roof = RoofNode.parse({ + id: 'roof_join', + type: 'roof', + children: ['rseg_mansard', 'rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: roof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + position: [3, 0, 0], + rotation: Math.PI / 2, + }) + const nodes = { [roof.id]: roof, [mansard.id]: mansard, [gable.id]: gable } + + const mansardWithSibling = generateRoofSegmentGeometry(mansard, nodes) + const ray = new THREE.Raycaster(new THREE.Vector3(3, 10, 0), new THREE.Vector3(0, -1, 0)) + + expect(ray.intersectObject(new THREE.Mesh(mansardWithSibling)).length).toBeGreaterThan(0) + + mansardWithSibling.dispose() + }) + + test('clips an entering gable created as a separate roof on the same level', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_mansard', 'roof_gable'], + }) + const mansardRoof = RoofNode.parse({ + id: 'roof_mansard', + type: 'roof', + parentId: level.id, + children: ['rseg_mansard'], + }) + const gableRoof = RoofNode.parse({ + id: 'roof_gable', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: mansardRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: gableRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const nodes = { + [level.id]: level, + [mansardRoof.id]: mansardRoof, + [gableRoof.id]: gableRoof, + [mansard.id]: mansard, + [gable.id]: gable, + } + + const unclipped = generateRoofSegmentGeometry(gable) + const clipped = generateRoofSegmentGeometry(gable, nodes) + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + expect(ray.intersectObject(new THREE.Mesh(unclipped)).length).toBeGreaterThan(0) + expect(ray.intersectObject(new THREE.Mesh(clipped))).toHaveLength(0) + + unclipped.dispose() + clipped.dispose() + }) + + test('keeps equal-area roof ownership stable when level children are reordered', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_z_gable', 'roof_a_mansard'], + }) + const mansardRoof = RoofNode.parse({ + id: 'roof_a_mansard', + type: 'roof', + parentId: level.id, + children: ['rseg_mansard'], + }) + const gableRoof = RoofNode.parse({ + id: 'roof_z_gable', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: mansardRoof.id, + roofType: 'mansard', + width: 10, + depth: 4, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: gableRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const nodes = { + [level.id]: level, + [mansardRoof.id]: mansardRoof, + [gableRoof.id]: gableRoof, + [mansard.id]: mansard, + [gable.id]: gable, + } + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + const unclipped = generateRoofSegmentGeometry(gable) + const clippedBeforeReorder = generateRoofSegmentGeometry(gable, nodes) + const unclippedHitCount = ray.intersectObject(new THREE.Mesh(unclipped)).length + const clippedBeforeHitCount = ray.intersectObject(new THREE.Mesh(clippedBeforeReorder)).length + expect(clippedBeforeHitCount).toBeLessThan(unclippedHitCount) + + const reorderedLevel = LevelNode.parse({ + ...level, + children: ['roof_a_mansard', 'roof_z_gable'], + }) + const clippedAfterReorder = generateRoofSegmentGeometry(gable, { + ...nodes, + [level.id]: reorderedLevel, + }) + expect(ray.intersectObject(new THREE.Mesh(clippedAfterReorder))).toHaveLength( + clippedBeforeHitCount, + ) + + unclipped.dispose() + clippedBeforeReorder.dispose() + clippedAfterReorder.dispose() + }) + + test('clips two entering roofs against one larger host roof', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_host', 'roof_east', 'roof_west'], + }) + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + parentId: level.id, + children: ['rseg_host'], + }) + const eastRoof = RoofNode.parse({ + id: 'roof_east', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_east'], + }) + const westRoof = RoofNode.parse({ + id: 'roof_west', + type: 'roof', + parentId: level.id, + position: [-3, 0, 0], + children: ['rseg_west'], + }) + const host = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 12, + depth: 10, + wallHeight: 3, + pitch: 30, + }) + const east = RoofSegmentNode.parse({ + id: 'rseg_east', + type: 'roof-segment', + parentId: eastRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const west = RoofSegmentNode.parse({ + ...east, + id: 'rseg_west', + parentId: westRoof.id, + }) + const nodes = { + [level.id]: level, + [hostRoof.id]: hostRoof, + [eastRoof.id]: eastRoof, + [westRoof.id]: westRoof, + [host.id]: host, + [east.id]: east, + [west.id]: west, + } + + const eastGeometry = generateRoofSegmentGeometry(east, nodes) + const westGeometry = generateRoofSegmentGeometry(west, nodes) + const eastRay = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + const westRay = new THREE.Raycaster(new THREE.Vector3(0, 10, 2), new THREE.Vector3(0, -1, 0)) + + expect(eastRay.intersectObject(new THREE.Mesh(eastGeometry))).toHaveLength(0) + expect(westRay.intersectObject(new THREE.Mesh(westGeometry))).toHaveLength(0) + + eastGeometry.dispose() + westGeometry.dispose() + }) + + test('clips a custom-footprint lean-to deck against a separate host roof', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_host', 'roof_lean_to'], + }) + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + parentId: level.id, + children: ['rseg_host'], + }) + const leanToRoof = RoofNode.parse({ + id: 'roof_lean_to', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_lean_to'], + }) + const host = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const leanTo = RoofSegmentNode.parse({ + id: 'rseg_lean_to', + type: 'roof-segment', + parentId: leanToRoof.id, + roofType: 'shed', + width: 8, + depth: 4, + wallHeight: 3, + pitch: 15, + overhang: 0, + shedFootprintPieces: [ + [ + [-4, -2], + [4, -2], + [4, 2], + [-4, 2], + ], + ], + }) + const nodes = { + [level.id]: level, + [hostRoof.id]: hostRoof, + [leanToRoof.id]: leanToRoof, + [host.id]: host, + [leanTo.id]: leanTo, + } + const unclipped = generateRoofSegmentGeometry(leanTo) + const clipped = generateRoofSegmentGeometry(leanTo, nodes) + + expect(clipped.getAttribute('position').count).not.toBe( + unclipped.getAttribute('position').count, + ) + + unclipped.dispose() + clipped.dispose() + }) + + test('uses every segment in a multi-segment host roof as an occluder', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_host', 'roof_entering'], + }) + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + parentId: level.id, + children: ['rseg_far', 'rseg_host'], + }) + const enteringRoof = RoofNode.parse({ + id: 'roof_entering', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_entering'], + }) + const farSegment = RoofSegmentNode.parse({ + id: 'rseg_far', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'gable', + width: 2, + depth: 2, + wallHeight: 3, + pitch: 30, + position: [20, 0, 0], + }) + const hostSegment = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const enteringSegment = RoofSegmentNode.parse({ + id: 'rseg_entering', + type: 'roof-segment', + parentId: enteringRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const nodes = { + [level.id]: level, + [hostRoof.id]: hostRoof, + [enteringRoof.id]: enteringRoof, + [farSegment.id]: farSegment, + [hostSegment.id]: hostSegment, + [enteringSegment.id]: enteringSegment, + } + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + const clipped = generateRoofSegmentGeometry(enteringSegment, nodes) + expect(ray.intersectObject(new THREE.Mesh(clipped))).toHaveLength(0) + + clipped.dispose() + }) +}) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index affd5c07b5..88577f7b95 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -5,6 +5,7 @@ import { getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, + getRoofPlanBounds, getRoofSegmentSurfaceY, getRoofShapeInsets, getRoofShapeRatios, @@ -15,8 +16,11 @@ import { normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, type RoofNode, + type RoofPlanBounds, type RoofSegmentNode, type RoofType, + roofOverlapEntryOwns, + roofPlanBoundsOverlap, sceneRegistry, useLiveNodeOverrides, useScene, @@ -27,7 +31,7 @@ import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferG import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { applyWorldScaleBoxUVs } from '../../lib/box-uv' -import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' +import { ensureRenderableGeometryAttributes, subtractCsgBrush } from '../../lib/csg-utils' function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry @@ -152,10 +156,68 @@ function createDegenerateRoofPlaceholder(): THREE.BufferGeometry { // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() +const previousRoofPlanBounds = new Map() const warnedMergedRoofNaNIds = new Set() const MAX_ROOFS_PER_FRAME = 1 const MAX_SEGMENTS_PER_FRAME = 3 +function queueSiblingRoofUpdates(roofId: AnyNodeId, nodes: Record) { + pendingRoofUpdates.add(roofId) + const roof = nodes[roofId]?.type === 'roof' ? getEffectiveNode(nodes[roofId]) : undefined + if (roof?.type !== 'roof' || !roof.parentId) return + const currentBounds = getRoofPlanBounds({ + position: roof.position, + rotation: roof.rotation, + segments: (roof.children ?? []).flatMap((id) => { + const segment = nodes[id as AnyNodeId] + if (segment?.type !== 'roof-segment') return [] + const effective = getEffectiveNode(segment) + return [ + { + position: effective.position, + rotation: effective.rotation, + width: effective.width, + depth: effective.depth, + }, + ] + }), + }) + const oldBounds = previousRoofPlanBounds.get(roofId) + if (currentBounds) previousRoofPlanBounds.set(roofId, currentBounds) + const parent = nodes[roof.parentId as AnyNodeId] + if (!parent || !('children' in parent) || !Array.isArray(parent.children)) return + for (const siblingId of parent.children) { + const sibling = nodes[siblingId as AnyNodeId] + if (sibling?.type !== 'roof' || sibling.id === roofId) continue + const effectiveSibling = getEffectiveNode(sibling) + const siblingBounds = getRoofPlanBounds({ + position: effectiveSibling.position, + rotation: effectiveSibling.rotation, + segments: (effectiveSibling.children ?? []).flatMap((id) => { + const segment = nodes[id as AnyNodeId] + if (segment?.type !== 'roof-segment') return [] + const effective = getEffectiveNode(segment) + return [ + { + position: effective.position, + rotation: effective.rotation, + width: effective.width, + depth: effective.depth, + }, + ] + }), + }) + if (!siblingBounds) continue + previousRoofPlanBounds.set(sibling.id, siblingBounds) + if ( + (currentBounds && roofPlanBoundsOverlap(currentBounds, siblingBounds)) || + (oldBounds && roofPlanBoundsOverlap(oldBounds, siblingBounds)) + ) { + pendingRoofUpdates.add(sibling.id) + } + } +} + // ============================================================================ // ROOF SYSTEM // ============================================================================ @@ -174,6 +236,7 @@ export const RoofSystem = () => { // Clear stale pending updates when the scene is unloaded if (rootNodeIds.length === 0) { pendingRoofUpdates.clear() + previousRoofPlanBounds.clear() warnedMergedRoofNaNIds.clear() for (const cached of mergedRoofSegmentGeometryCache.values()) { disposeCachedMergedRoofSegmentGeometrySet(cached) @@ -259,10 +322,10 @@ export const RoofSystem = () => { } // Queue the parent roof for a merged geometry update if (effectiveSegment.parentId) { - pendingRoofUpdates.add(effectiveSegment.parentId as AnyNodeId) + queueSiblingRoofUpdates(effectiveSegment.parentId as AnyNodeId, nodes) } } else if (node.type === 'roof') { - pendingRoofUpdates.add(id as AnyNodeId) + queueSiblingRoofUpdates(id as AnyNodeId, nodes) clearDirty(id as AnyNodeId) } }) @@ -508,8 +571,7 @@ function updateMergedRoofGeometry( let totalShinSlab: Brush | null = null let totalDeckSlab: Brush | null = null - let totalWall: Brush | null = null - let totalInner: Brush | null = null + let totalWallShell: Brush | null = null const rakeBoardGeometries: THREE.BufferGeometry[] = [] const directSegmentGeometries: THREE.BufferGeometry[] = [] const csgChildren: RoofSegmentNode[] = [] @@ -525,13 +587,14 @@ function updateMergedRoofGeometry( () => buildCustomShedGeometry(child), ) if (directGeometry) { - const withPanels = addShedInsetEndPanels(directGeometry, [child], false) + let withPanels = addShedInsetEndPanels(directGeometry, [child], false) _matrix.compose( _position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale, ) withPanels.applyMatrix4(_matrix) + withPanels = clipDirectRoofGeometryAgainstSiblings(withPanels, child, nodes, 'roof') directSegmentGeometries.push(withPanels) continue } @@ -542,6 +605,17 @@ function updateMergedRoofGeometry( rakeBoardGeometries.push(brushes.rakeBoards) } + const occludingInterior = buildOccludingRoofInterior(child, nodes, 'roof') + if (occludingInterior) { + const exposedShingles = subtractCsgBrush(brushes.shinSlab, occludingInterior, csgEvaluator) + brushes.shinSlab.geometry.dispose() + brushes.shinSlab = exposedShingles + + const exposedDeck = subtractCsgBrush(brushes.deckSlab, occludingInterior, csgEvaluator) + brushes.deckSlab.geometry.dispose() + brushes.deckSlab = exposedDeck + } + if (totalShinSlab) { const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush totalShinSlab.geometry.dispose() @@ -566,26 +640,32 @@ function updateMergedRoofGeometry( brushes.wallBrush.geometry.dispose() brushes.innerBrush.geometry.dispose() } else { - if (totalWall) { - const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush - totalWall.geometry.dispose() - brushes.wallBrush.geometry.dispose() - prepareBrushForCSG(next) - totalWall = next - } else { - totalWall = brushes.wallBrush + let wallShell = csgEvaluator.evaluate( + brushes.wallBrush, + brushes.innerBrush, + SUBTRACTION, + ) as Brush + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + prepareBrushForCSG(wallShell) + + if (occludingInterior) { + const exposedWall = subtractCsgBrush(wallShell, occludingInterior, csgEvaluator) + wallShell.geometry.dispose() + wallShell = exposedWall } - if (totalInner) { - const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush - totalInner.geometry.dispose() - brushes.innerBrush.geometry.dispose() + if (totalWallShell) { + const next = csgEvaluator.evaluate(totalWallShell, wallShell, ADDITION) as Brush + totalWallShell.geometry.dispose() + wallShell.geometry.dispose() prepareBrushForCSG(next) - totalInner = next + totalWallShell = next } else { - totalInner = brushes.innerBrush + totalWallShell = wallShell } } + occludingInterior?.geometry.dispose() } if (totalShinSlab && totalDeckSlab) { @@ -593,11 +673,8 @@ function updateMergedRoofGeometry( const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION) prepareBrushForCSG(shinDeck) let combined = shinDeck - let finalWallTrimmed: Brush | null = null - if (totalWall && totalInner) { - finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) - prepareBrushForCSG(finalWallTrimmed) - combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) + if (totalWallShell) { + combined = csgEvaluator.evaluate(shinDeck, totalWallShell, ADDITION) } prepareBrushForCSG(combined) @@ -611,12 +688,10 @@ function updateMergedRoofGeometry( warnedMergedRoofNaNIds.add(roofNode.id) } resultGeo.dispose() - finalWallTrimmed?.geometry.dispose() if (combined !== shinDeck) shinDeck.geometry.dispose() totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() - totalWall?.geometry.dispose() - totalInner?.geometry.dispose() + totalWallShell?.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() for (const geometry of directSegmentGeometries) geometry.dispose() return @@ -653,7 +728,6 @@ function updateMergedRoofGeometry( mergedMesh.geometry.dispose() mergedMesh.geometry = finalGeo - finalWallTrimmed?.geometry.dispose() if (combined !== shinDeck) shinDeck.geometry.dispose() } catch (e) { console.error('Merged roof CSG failed:', e) @@ -661,8 +735,7 @@ function updateMergedRoofGeometry( totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() - totalWall?.geometry.dispose() - totalInner?.geometry.dispose() + totalWallShell?.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() } @@ -1585,7 +1658,10 @@ export function generateRoofSegmentGeometry( buildCustomShedGeometry(node), ) if (directShedGeometry) { - const result = addShedInsetEndPanels(directShedGeometry, [node], false) + let result = addShedInsetEndPanels(directShedGeometry, [node], false) + if (nodes) { + result = clipDirectRoofGeometryAgainstSiblings(result, node, nodes, 'segment') + } result.computeVertexNormals() ensureRenderableGeometryAttributes(result) return result @@ -1615,6 +1691,14 @@ export function generateRoofSegmentGeometry( } prepareBrushForCSG(combined) + const siblingInterior = nodes ? buildOccludingRoofInterior(node, nodes, 'segment') : null + if (siblingInterior) { + const unclipped = combined + combined = subtractCsgBrush(unclipped, siblingInterior, csgEvaluator) + unclipped.geometry.dispose() + siblingInterior.geometry.dispose() + } + resultGeo = csgGeometry(combined) if (geometryHasInvalidAttributes(resultGeo)) { resultGeo.dispose() @@ -1667,6 +1751,154 @@ export function generateRoofSegmentGeometry( return resultGeo } +function clipDirectRoofGeometryAgainstSiblings( + geometry: THREE.BufferGeometry, + node: RoofSegmentNode, + nodes: Record, + space: 'roof' | 'segment', +): THREE.BufferGeometry { + const siblingInterior = buildOccludingRoofInterior(node, nodes, space) + if (!siblingInterior) return geometry + + const brush = new Brush(geometry, dummyMats) + prepareBrushForCSG(brush) + try { + const clipped = subtractCsgBrush(brush, siblingInterior, csgEvaluator) + const clippedGeometry = csgGeometry(clipped) + const clippedMaterials = csgMaterials(clipped) + const materialIndices = new Map([ + [dummyMats[0], 0], + [dummyMats[1], 1], + [dummyMats[2], 2], + [dummyMats[3], 3], + ]) + for (const group of clippedGeometry.groups) { + group.materialIndex = mapRoofGroupMaterialIndex( + group.materialIndex, + clippedMaterials, + materialIndices, + ) + } + geometry.dispose() + clippedGeometry.computeVertexNormals() + ensureRenderableGeometryAttributes(clippedGeometry) + return clippedGeometry + } catch (error) { + console.error('Direct roof intersection CSG failed:', error) + return geometry + } finally { + siblingInterior.geometry.dispose() + } +} + +function buildOccludingRoofInterior( + node: RoofSegmentNode, + nodes: Record, + space: 'roof' | 'segment', +): Brush | null { + if (!node.parentId) return null + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type !== 'roof') return null + + const roofEntries = collectSiblingRoofEntries(parent, nodes) + const currentEntry = roofEntries.find(({ segment }) => segment.id === node.id) + if (!currentEntry) return null + const targetRoofInverse = composeRoofTransform(parent).invert() + const targetSegmentInverse = composeSegmentTransform(node).invert() + let combinedInterior: Brush | null = null + + for (let siblingIndex = 0; siblingIndex < roofEntries.length; siblingIndex++) { + const entry = roofEntries[siblingIndex]! + const sibling = entry.segment + if (sibling.id === node.id) continue + if (sibling.roofType === 'shed') continue + const siblingOwnsOverlap = roofOverlapEntryOwns( + { + roofId: String(entry.roof.id), + segmentId: String(sibling.id), + width: sibling.width, + depth: sibling.depth, + }, + { + roofId: String(currentEntry.roof.id), + segmentId: String(node.id), + width: node.width, + depth: node.depth, + }, + ) + if (!siblingOwnsOverlap) continue + const siblingBrushes = getRoofSegmentBrushes(sibling) + if (!siblingBrushes) continue + + const siblingInTargetRoof = new THREE.Matrix4() + .multiplyMatrices(targetRoofInverse, composeRoofTransform(entry.roof)) + .multiply(composeSegmentTransform(sibling)) + const relativeMatrix = + space === 'segment' + ? new THREE.Matrix4().multiplyMatrices(targetSegmentInverse, siblingInTargetRoof) + : siblingInTargetRoof + csgGeometry(siblingBrushes.innerBrush).applyMatrix4(relativeMatrix) + siblingBrushes.innerBrush.updateMatrixWorld() + + siblingBrushes.shinSlab.geometry.dispose() + siblingBrushes.deckSlab.geometry.dispose() + siblingBrushes.wallBrush.geometry.dispose() + siblingBrushes.rakeBoards?.dispose() + + if (combinedInterior) { + const next = csgEvaluator.evaluate( + combinedInterior, + siblingBrushes.innerBrush, + ADDITION, + ) as Brush + combinedInterior.geometry.dispose() + siblingBrushes.innerBrush.geometry.dispose() + prepareBrushForCSG(next) + combinedInterior = next + } else { + combinedInterior = siblingBrushes.innerBrush + } + } + + return combinedInterior +} + +function collectSiblingRoofEntries( + targetRoof: RoofNode, + nodes: Record, +): Array<{ roof: RoofNode; segment: RoofSegmentNode }> { + const parent = targetRoof.parentId ? nodes[targetRoof.parentId as AnyNodeId] : undefined + const orderedRoofIds = + parent && 'children' in parent && Array.isArray(parent.children) + ? parent.children.filter((id): id is RoofNode['id'] => nodes[id]?.type === 'roof') + : [targetRoof.id] + if (!orderedRoofIds.includes(targetRoof.id)) orderedRoofIds.push(targetRoof.id) + + return orderedRoofIds.flatMap((roofId) => { + const roof = getEffectiveNode(nodes[roofId] as RoofNode) + return (roof.children ?? []).flatMap((segmentId) => { + const segment = nodes[segmentId as AnyNodeId] + return segment?.type === 'roof-segment' ? [{ roof, segment: getEffectiveNode(segment) }] : [] + }) + }) +} + +function composeRoofTransform(roof: RoofNode): THREE.Matrix4 { + return new THREE.Matrix4().compose( + new THREE.Vector3(...roof.position), + new THREE.Quaternion().setFromAxisAngle(_yAxis, roof.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ) +} + +function composeSegmentTransform(segment: RoofSegmentNode): THREE.Matrix4 { + return new THREE.Matrix4().compose( + new THREE.Vector3(...segment.position), + new THREE.Quaternion().setFromAxisAngle(_yAxis, segment.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ) +} + // ============================================================================ // FACE-BASED GEOMETRY HELPERS (ported from prototype) // ============================================================================ diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index b265b22969..13c52c3a59 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -5,9 +5,9 @@ import { polygonsIntersect, type SlabNode, type SlabPolygonContext, + subtractPolygonsFromPolygon, } from '@pascal-app/core' import * as THREE from 'three' -import { subtractPolygonsFromPolygon } from '../../lib/polygon-union' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' // ============================================================================ diff --git a/packages/viewer/src/systems/surface-hole-geometry.ts b/packages/viewer/src/systems/surface-hole-geometry.ts index bd514ea008..6f427ae515 100644 --- a/packages/viewer/src/systems/surface-hole-geometry.ts +++ b/packages/viewer/src/systems/surface-hole-geometry.ts @@ -1,4 +1,4 @@ -import { type Point2D, unionPolygons } from '../lib/polygon-union' +import { type PolygonBooleanPoint2D as Point2D, unionPolygons } from '@pascal-app/core' export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] { return unionPolygons(holes)